feat(Analyzer): refactor analyzer and implement auto mode
This commit is contained in:
@@ -0,0 +1,190 @@
|
||||
using System;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using FinlyticAnalyzer.Database;
|
||||
using FinlyticAnalyzer.Entities;
|
||||
using FinlyticAnalyzer.Services;
|
||||
using FinlyticCore.Models.Analyzer;
|
||||
using FinlyticCore.Models.Trades;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace FinlyticAnalyzer.Controllers;
|
||||
|
||||
public class ManualAnalysisRequest
|
||||
{
|
||||
public string Symbol { get; set; } = string.Empty;
|
||||
public string Isin { get; set; } = string.Empty;
|
||||
public string Sector { get; set; } = "Technology";
|
||||
public string Headline { get; set; } = "Manual User Request";
|
||||
public decimal CurrentPrice { get; set; } = 100.0m;
|
||||
public int RiskScore { get; set; } = 50; // 0 to 100
|
||||
public int MinTimeframeValue { get; set; } = 4;
|
||||
public int MaxTimeframeValue { get; set; } = 6;
|
||||
public string TimeframeUnit { get; set; } = "Tage";
|
||||
public string InstrumentType { get; set; } = "Stock";
|
||||
public string UserNotes { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
[ApiController]
|
||||
[Route("api/v1/analyze")]
|
||||
public class ManualAnalysisController : ControllerBase
|
||||
{
|
||||
private readonly IVixTrackerService _vixTracker;
|
||||
private readonly IN8nEvaluationService _n8nService;
|
||||
private readonly IWinRateCalculator _winRateCalculator;
|
||||
private readonly AnalyzerDbContext _dbContext;
|
||||
private readonly ILogger<ManualAnalysisController> _logger;
|
||||
|
||||
public ManualAnalysisController(
|
||||
IVixTrackerService vixTracker,
|
||||
IN8nEvaluationService n8nService,
|
||||
IWinRateCalculator winRateCalculator,
|
||||
AnalyzerDbContext dbContext,
|
||||
ILogger<ManualAnalysisController> logger)
|
||||
{
|
||||
_vixTracker = vixTracker;
|
||||
_n8nService = n8nService;
|
||||
_winRateCalculator = winRateCalculator;
|
||||
_dbContext = dbContext;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Runs a manual analysis based on the provided request.
|
||||
/// </summary>
|
||||
[HttpPost("manual")]
|
||||
public async Task<IActionResult> RunManualAnalysis([FromBody] ManualAnalysisRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request.Symbol) && string.IsNullOrWhiteSpace(request.Isin))
|
||||
{
|
||||
return BadRequest(new { error = "Symbol or ISIN is required." });
|
||||
}
|
||||
|
||||
var regime = _vixTracker.GetCurrentRegime();
|
||||
var currentVix = _vixTracker.GetCurrentVix();
|
||||
string analysisId = Guid.NewGuid().ToString("N");
|
||||
double winRate = _winRateCalculator.CalculateWinRate(request.Sector, request.Symbol, regime);
|
||||
|
||||
string riskLabel = request.RiskScore > 70 ? $"Aggressiv ({request.RiskScore}/100)" : (request.RiskScore > 30 ? $"Balanced ({request.RiskScore}/100)" : $"Konservativ ({request.RiskScore}/100)");
|
||||
string timeframeFormatted = $"{request.MinTimeframeValue}-{request.MaxTimeframeValue} {request.TimeframeUnit}";
|
||||
|
||||
var n8nRequest = new N8nAnalysisRequestDto
|
||||
{
|
||||
RequestId = analysisId,
|
||||
Timestamp = DateTime.UtcNow,
|
||||
TriggerType = "Manual",
|
||||
TargetAsset = new TargetAssetInfo
|
||||
{
|
||||
Symbol = request.Symbol.ToUpperInvariant(),
|
||||
Isin = request.Isin.ToUpperInvariant(),
|
||||
Sector = request.Sector
|
||||
},
|
||||
MarketContext = new MarketContextInfo
|
||||
{
|
||||
Vix = currentVix,
|
||||
MarketRegime = regime.ToString()
|
||||
},
|
||||
FilterContext = new FilterContextInfo
|
||||
{
|
||||
ImpactScore = 1.0,
|
||||
RawNewsHeadline = string.IsNullOrWhiteSpace(request.Headline) ? "Manual User Trigger" : request.Headline
|
||||
},
|
||||
UserPreferences = new UserPreferencesInfo
|
||||
{
|
||||
RiskScore = request.RiskScore,
|
||||
RiskTolerance = riskLabel,
|
||||
MinTimeframeValue = request.MinTimeframeValue,
|
||||
MaxTimeframeValue = request.MaxTimeframeValue,
|
||||
TimeframeUnit = request.TimeframeUnit,
|
||||
TimeframeFormatted = timeframeFormatted,
|
||||
InstrumentType = request.InstrumentType,
|
||||
UserNotes = request.UserNotes
|
||||
},
|
||||
TradeFeedback = new TradeFeedbackInfo
|
||||
{
|
||||
TotalAssetTrades = 12,
|
||||
AssetWinRate = winRate,
|
||||
AvgReturnPercent = 3.4,
|
||||
LastTradeResult = "WIN"
|
||||
}
|
||||
};
|
||||
|
||||
var n8nResponse = await _n8nService.EvaluateAssetAsync(n8nRequest, cancellationToken);
|
||||
bool shouldProceed = n8nResponse != null && string.Equals(n8nResponse.AiDecision, "Proceed", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
TradeProposalDto? proposal = null;
|
||||
if (shouldProceed && n8nResponse != null)
|
||||
{
|
||||
proposal = new TradeProposalDto
|
||||
{
|
||||
AnalysisId = analysisId,
|
||||
EventId = analysisId,
|
||||
Sector = request.Sector,
|
||||
Symbol = request.Symbol.ToUpperInvariant(),
|
||||
Isin = request.Isin.ToUpperInvariant(),
|
||||
CompanyName = request.Symbol,
|
||||
EntryPrice = request.CurrentPrice,
|
||||
SignalType = string.Equals(n8nResponse.SuggestedDirection, "Short", StringComparison.OrdinalIgnoreCase) ? "SELL" : "BUY",
|
||||
RiskTolerance = n8nResponse.SuggestedRisk,
|
||||
Timeframe = timeframeFormatted,
|
||||
InstrumentType = request.InstrumentType,
|
||||
WinRate = winRate,
|
||||
VixRegime = regime,
|
||||
VixValue = currentVix,
|
||||
TtlMinutes = 60,
|
||||
Reasoning = $"Manual n8n Evaluation ({n8nResponse.AiDecision}): {n8nResponse.AiReasoning}",
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
}
|
||||
|
||||
var analysisEntity = new AnalysisEntity
|
||||
{
|
||||
AnalysisId = analysisId,
|
||||
EventId = analysisId,
|
||||
Sector = request.Sector,
|
||||
Symbol = request.Symbol.ToUpperInvariant(),
|
||||
Isin = request.Isin.ToUpperInvariant(),
|
||||
VixRegime = regime,
|
||||
VixValue = currentVix,
|
||||
ImpactScore = 1.0,
|
||||
WinRate = winRate,
|
||||
RawDataJson = JsonSerializer.Serialize(request),
|
||||
AiOutputJson = proposal != null ? JsonSerializer.Serialize(proposal) : "{}",
|
||||
N8nResponseJson = n8nResponse != null ? JsonSerializer.Serialize(n8nResponse) : "{}",
|
||||
N8nEvalScore = n8nResponse?.EvalScore ?? 0,
|
||||
N8nDecision = n8nResponse?.AiDecision ?? "Rejected",
|
||||
IsTradeProposed = shouldProceed,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
_dbContext.Analyses.Add(analysisEntity);
|
||||
await _dbContext.SaveChangesAsync(cancellationToken);
|
||||
|
||||
if (!shouldProceed)
|
||||
{
|
||||
return Ok(new
|
||||
{
|
||||
analysisId,
|
||||
isTradeProposed = false,
|
||||
status = "Rejected",
|
||||
recommendation = "NOT_RECOMMENDED",
|
||||
reasoning = n8nResponse?.AiReasoning ?? "Die KI stuft diesen Trade als zu riskant ein und empfiehlt keine Positionierung.",
|
||||
n8nResponse,
|
||||
proposal = (object?)null
|
||||
});
|
||||
}
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
analysisId,
|
||||
isTradeProposed = true,
|
||||
status = "Success",
|
||||
recommendation = "RECOMMENDED",
|
||||
n8nResponse,
|
||||
proposal
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
using FinlyticAnalyzer.Entities;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace FinlyticAnalyzer.Database;
|
||||
|
||||
public class AnalyzerDbContext : DbContext
|
||||
{
|
||||
public AnalyzerDbContext(DbContextOptions<AnalyzerDbContext> options) : base(options) { }
|
||||
|
||||
public DbSet<AnalysisEntity> Analyses => Set<AnalysisEntity>();
|
||||
public DbSet<AnalyzerSettingsEntity> Settings => Set<AnalyzerSettingsEntity>();
|
||||
public DbSet<TradeProposalEntity> TradeProposals => Set<TradeProposalEntity>();
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
base.OnModelCreating(modelBuilder);
|
||||
|
||||
modelBuilder.Entity<AnalysisEntity>(entity =>
|
||||
{
|
||||
entity.HasIndex(e => e.AnalysisId).IsUnique();
|
||||
entity.HasIndex(e => e.EventId);
|
||||
entity.HasIndex(e => e.Isin);
|
||||
entity.HasIndex(e => e.Sector);
|
||||
entity.HasIndex(e => e.CreatedAt);
|
||||
});
|
||||
|
||||
modelBuilder.Entity<TradeProposalEntity>(entity =>
|
||||
{
|
||||
entity.HasIndex(e => e.Isin);
|
||||
entity.HasIndex(e => e.ExpiresAt);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
|
||||
WORKDIR /src
|
||||
COPY ["FinlyticCore/FinlyticCore.csproj", "FinlyticCore/"]
|
||||
COPY ["FinlyticAnalyzer/FinlyticAnalyzer.csproj", "FinlyticAnalyzer/"]
|
||||
RUN dotnet restore "FinlyticAnalyzer/FinlyticAnalyzer.csproj"
|
||||
COPY . .
|
||||
WORKDIR "/src/FinlyticAnalyzer"
|
||||
RUN dotnet build "FinlyticAnalyzer.csproj" -c Release -o /app/build
|
||||
|
||||
FROM build AS publish
|
||||
RUN dotnet publish "FinlyticAnalyzer.csproj" -c Release -o /app/publish /p:UseAppHost=false
|
||||
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS final
|
||||
WORKDIR /app
|
||||
COPY --from=publish /app/publish .
|
||||
ENTRYPOINT ["dotnet", "FinlyticAnalyzer.dll"]
|
||||
@@ -0,0 +1,60 @@
|
||||
using System;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
using FinlyticCore.Models.Analyzer;
|
||||
|
||||
namespace FinlyticAnalyzer.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Persisted raw news, market context, AI prompt payload & response in PostgreSQL.
|
||||
/// </summary>
|
||||
[Table("analyses")]
|
||||
public class AnalysisEntity
|
||||
{
|
||||
[Key]
|
||||
public Guid Id { get; set; } = Guid.NewGuid();
|
||||
|
||||
[Required]
|
||||
[MaxLength(100)]
|
||||
public string AnalysisId { get; set; } = string.Empty;
|
||||
|
||||
[Required]
|
||||
[MaxLength(100)]
|
||||
public string EventId { get; set; } = string.Empty;
|
||||
|
||||
[Required]
|
||||
[MaxLength(50)]
|
||||
public string Sector { get; set; } = string.Empty;
|
||||
|
||||
[Required]
|
||||
[MaxLength(30)]
|
||||
public string Symbol { get; set; } = string.Empty;
|
||||
|
||||
[Required]
|
||||
[MaxLength(30)]
|
||||
public string Isin { get; set; } = string.Empty;
|
||||
|
||||
public VixMarketRegime VixRegime { get; set; }
|
||||
public decimal VixValue { get; set; }
|
||||
|
||||
public double ImpactScore { get; set; }
|
||||
public double WinRate { get; set; }
|
||||
|
||||
[Column(TypeName = "jsonb")]
|
||||
public string RawDataJson { get; set; } = "{}";
|
||||
|
||||
[Column(TypeName = "jsonb")]
|
||||
public string AiOutputJson { get; set; } = "{}";
|
||||
|
||||
[Column(TypeName = "jsonb")]
|
||||
public string N8nResponseJson { get; set; } = "{}";
|
||||
|
||||
public double N8nEvalScore { get; set; }
|
||||
|
||||
[MaxLength(30)]
|
||||
public string N8nDecision { get; set; } = string.Empty;
|
||||
|
||||
public bool IsTradeProposed { get; set; }
|
||||
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
using System;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace FinlyticAnalyzer.Entities;
|
||||
|
||||
public class AnalyzerSettingsEntity
|
||||
{
|
||||
[Key]
|
||||
public Guid Id { get; set; }
|
||||
|
||||
public string ScanCronSchedule { get; set; } = "0 */1 * * *";
|
||||
public double MinSignalScore { get; set; } = 75.0;
|
||||
|
||||
public bool EnableLogMqttHealthPing { get; set; } = false;
|
||||
public bool EnableLogMqttGeneral { get; set; } = true;
|
||||
public bool EnableLogAnalyzerAuto { get; set; } = true;
|
||||
public bool EnableLogAnalyzerManual { get; set; } = true;
|
||||
public bool EnableLogDatabaseOps { get; set; } = true;
|
||||
|
||||
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
using FinlyticCore.Models.Analyzer;
|
||||
using FinlyticCore.Models.Assets;
|
||||
|
||||
namespace FinlyticAnalyzer.Entities;
|
||||
|
||||
[Table("trade_proposals")]
|
||||
public class TradeProposalEntity
|
||||
{
|
||||
[Key]
|
||||
public Guid Id { get; set; } = Guid.NewGuid();
|
||||
|
||||
[Required]
|
||||
[MaxLength(100)]
|
||||
public string AnalysisId { get; set; } = string.Empty;
|
||||
|
||||
[Required]
|
||||
[MaxLength(100)]
|
||||
public string EventId { get; set; } = string.Empty;
|
||||
|
||||
[Required]
|
||||
[MaxLength(30)]
|
||||
public string Isin { get; set; } = string.Empty;
|
||||
|
||||
[MaxLength(30)]
|
||||
public string Symbol { get; set; } = string.Empty;
|
||||
|
||||
[MaxLength(150)]
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
[MaxLength(50)]
|
||||
public string Sector { get; set; } = "General";
|
||||
|
||||
public AssetType Type { get; set; } = AssetType.Stock;
|
||||
|
||||
/// <summary>
|
||||
/// KI-Entscheidung ("BUY", "SELL", "HOLD", "REJECTED")
|
||||
/// </summary>
|
||||
[MaxLength(20)]
|
||||
public string ProposedAction { get; set; } = "BUY";
|
||||
|
||||
public double ConfidenceScore { get; set; }
|
||||
|
||||
// --- KI Execution Plan (Vorgeschlagene Preismarken) ---
|
||||
[Column(TypeName = "decimal(18,4)")]
|
||||
public decimal EntryPrice { get; set; }
|
||||
|
||||
[Column(TypeName = "decimal(18,4)")]
|
||||
public decimal StopLoss { get; set; }
|
||||
|
||||
[Column(TypeName = "decimal(18,4)")]
|
||||
public decimal TakeProfit { get; set; }
|
||||
|
||||
[Column(TypeName = "decimal(18,4)")]
|
||||
public decimal? EntryZoneMin { get; set; }
|
||||
|
||||
[Column(TypeName = "decimal(18,4)")]
|
||||
public decimal? EntryZoneMax { get; set; }
|
||||
|
||||
public string? TakeProfitTargets { get; set; } // Comma-separated or JSON
|
||||
|
||||
[Column(TypeName = "decimal(18,4)")]
|
||||
public decimal? RiskRewardRatio { get; set; }
|
||||
|
||||
[Column(TypeName = "decimal(18,4)")]
|
||||
public decimal? MaxLeverage { get; set; }
|
||||
|
||||
// --- Kontext aus Request & KI ---
|
||||
public string ReasonSummary { get; set; } = string.Empty;
|
||||
public string TechnicalRationale { get; set; } = string.Empty;
|
||||
public string FundamentalRationale { get; set; } = string.Empty;
|
||||
public string RiskWarning { get; set; } = string.Empty;
|
||||
|
||||
[MaxLength(30)]
|
||||
public string RiskTolerance { get; set; } = "Balanced";
|
||||
|
||||
[MaxLength(20)]
|
||||
public string Timeframe { get; set; } = "1-7 Tage";
|
||||
|
||||
[MaxLength(30)]
|
||||
public string InstrumentType { get; set; } = "KnockOut";
|
||||
|
||||
public VixMarketRegime VixRegime { get; set; }
|
||||
|
||||
[Column(TypeName = "decimal(18,4)")]
|
||||
public decimal VixValue { get; set; }
|
||||
|
||||
public double WinRate { get; set; }
|
||||
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
public DateTime ExpiresAt { get; set; } = DateTime.UtcNow.AddHours(3);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</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.Extensions.Hosting" Version="10.0.9" />
|
||||
<PackageReference Include="Microsoft.Extensions.Http" Version="10.0.9" />
|
||||
<PackageReference Include="MQTTnet" Version="5.1.0.1559" />
|
||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.2" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\FinlyticCore\FinlyticCore.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,136 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using FinlyticAnalyzer.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 FinlyticAnalyzer.Migrations
|
||||
{
|
||||
[DbContext(typeof(AnalyzerDbContext))]
|
||||
[Migration("20260801073402_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("FinlyticAnalyzer.Entities.AnalysisEntity", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("AiOutputJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("jsonb");
|
||||
|
||||
b.Property<string>("AnalysisId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("EventId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<double>("ImpactScore")
|
||||
.HasColumnType("double precision");
|
||||
|
||||
b.Property<bool>("IsTradeProposed")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("Isin")
|
||||
.IsRequired()
|
||||
.HasMaxLength(30)
|
||||
.HasColumnType("character varying(30)");
|
||||
|
||||
b.Property<string>("N8nDecision")
|
||||
.IsRequired()
|
||||
.HasMaxLength(30)
|
||||
.HasColumnType("character varying(30)");
|
||||
|
||||
b.Property<double>("N8nEvalScore")
|
||||
.HasColumnType("double precision");
|
||||
|
||||
b.Property<string>("N8nResponseJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("jsonb");
|
||||
|
||||
b.Property<string>("RawDataJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("jsonb");
|
||||
|
||||
b.Property<string>("Sector")
|
||||
.IsRequired()
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("character varying(50)");
|
||||
|
||||
b.Property<string>("Symbol")
|
||||
.IsRequired()
|
||||
.HasMaxLength(30)
|
||||
.HasColumnType("character varying(30)");
|
||||
|
||||
b.Property<int>("VixRegime")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<decimal>("VixValue")
|
||||
.HasColumnType("numeric");
|
||||
|
||||
b.Property<double>("WinRate")
|
||||
.HasColumnType("double precision");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("AnalysisId")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("CreatedAt");
|
||||
|
||||
b.HasIndex("EventId");
|
||||
|
||||
b.HasIndex("Isin");
|
||||
|
||||
b.HasIndex("Sector");
|
||||
|
||||
b.ToTable("analyses");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("FinlyticAnalyzer.Entities.AnalyzerSettingsEntity", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<double>("MinSignalScore")
|
||||
.HasColumnType("double precision");
|
||||
|
||||
b.Property<string>("ScanCronSchedule")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("Settings");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace FinlyticAnalyzer.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class Init : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "analyses",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
AnalysisId = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
|
||||
EventId = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
|
||||
Sector = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: false),
|
||||
Symbol = table.Column<string>(type: "character varying(30)", maxLength: 30, nullable: false),
|
||||
Isin = table.Column<string>(type: "character varying(30)", maxLength: 30, nullable: false),
|
||||
VixRegime = table.Column<int>(type: "integer", nullable: false),
|
||||
VixValue = table.Column<decimal>(type: "numeric", nullable: false),
|
||||
ImpactScore = table.Column<double>(type: "double precision", nullable: false),
|
||||
WinRate = table.Column<double>(type: "double precision", nullable: false),
|
||||
RawDataJson = table.Column<string>(type: "jsonb", nullable: false),
|
||||
AiOutputJson = table.Column<string>(type: "jsonb", nullable: false),
|
||||
N8nResponseJson = table.Column<string>(type: "jsonb", nullable: false),
|
||||
N8nEvalScore = table.Column<double>(type: "double precision", nullable: false),
|
||||
N8nDecision = table.Column<string>(type: "character varying(30)", maxLength: 30, nullable: false),
|
||||
IsTradeProposed = table.Column<bool>(type: "boolean", nullable: false),
|
||||
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_analyses", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "Settings",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
ScanCronSchedule = table.Column<string>(type: "text", nullable: false),
|
||||
MinSignalScore = table.Column<double>(type: "double precision", nullable: false),
|
||||
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_Settings", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_analyses_AnalysisId",
|
||||
table: "analyses",
|
||||
column: "AnalysisId",
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_analyses_CreatedAt",
|
||||
table: "analyses",
|
||||
column: "CreatedAt");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_analyses_EventId",
|
||||
table: "analyses",
|
||||
column: "EventId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_analyses_Isin",
|
||||
table: "analyses",
|
||||
column: "Isin");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_analyses_Sector",
|
||||
table: "analyses",
|
||||
column: "Sector");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "analyses");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "Settings");
|
||||
}
|
||||
}
|
||||
}
|
||||
+151
@@ -0,0 +1,151 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using FinlyticAnalyzer.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 FinlyticAnalyzer.Migrations
|
||||
{
|
||||
[DbContext(typeof(AnalyzerDbContext))]
|
||||
[Migration("20260803185020_AddLogFilterSettings")]
|
||||
partial class AddLogFilterSettings
|
||||
{
|
||||
/// <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("FinlyticAnalyzer.Entities.AnalysisEntity", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("AiOutputJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("jsonb");
|
||||
|
||||
b.Property<string>("AnalysisId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("EventId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<double>("ImpactScore")
|
||||
.HasColumnType("double precision");
|
||||
|
||||
b.Property<bool>("IsTradeProposed")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("Isin")
|
||||
.IsRequired()
|
||||
.HasMaxLength(30)
|
||||
.HasColumnType("character varying(30)");
|
||||
|
||||
b.Property<string>("N8nDecision")
|
||||
.IsRequired()
|
||||
.HasMaxLength(30)
|
||||
.HasColumnType("character varying(30)");
|
||||
|
||||
b.Property<double>("N8nEvalScore")
|
||||
.HasColumnType("double precision");
|
||||
|
||||
b.Property<string>("N8nResponseJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("jsonb");
|
||||
|
||||
b.Property<string>("RawDataJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("jsonb");
|
||||
|
||||
b.Property<string>("Sector")
|
||||
.IsRequired()
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("character varying(50)");
|
||||
|
||||
b.Property<string>("Symbol")
|
||||
.IsRequired()
|
||||
.HasMaxLength(30)
|
||||
.HasColumnType("character varying(30)");
|
||||
|
||||
b.Property<int>("VixRegime")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<decimal>("VixValue")
|
||||
.HasColumnType("numeric");
|
||||
|
||||
b.Property<double>("WinRate")
|
||||
.HasColumnType("double precision");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("AnalysisId")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("CreatedAt");
|
||||
|
||||
b.HasIndex("EventId");
|
||||
|
||||
b.HasIndex("Isin");
|
||||
|
||||
b.HasIndex("Sector");
|
||||
|
||||
b.ToTable("analyses");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("FinlyticAnalyzer.Entities.AnalyzerSettingsEntity", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<bool>("EnableLogAnalyzerAuto")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<bool>("EnableLogAnalyzerManual")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<bool>("EnableLogDatabaseOps")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<bool>("EnableLogMqttGeneral")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<bool>("EnableLogMqttHealthPing")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<double>("MinSignalScore")
|
||||
.HasColumnType("double precision");
|
||||
|
||||
b.Property<string>("ScanCronSchedule")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("Settings");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace FinlyticAnalyzer.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddLogFilterSettings : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "EnableLogAnalyzerAuto",
|
||||
table: "Settings",
|
||||
type: "boolean",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "EnableLogAnalyzerManual",
|
||||
table: "Settings",
|
||||
type: "boolean",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "EnableLogDatabaseOps",
|
||||
table: "Settings",
|
||||
type: "boolean",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "EnableLogMqttGeneral",
|
||||
table: "Settings",
|
||||
type: "boolean",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "EnableLogMqttHealthPing",
|
||||
table: "Settings",
|
||||
type: "boolean",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "EnableLogAnalyzerAuto",
|
||||
table: "Settings");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "EnableLogAnalyzerManual",
|
||||
table: "Settings");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "EnableLogDatabaseOps",
|
||||
table: "Settings");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "EnableLogMqttGeneral",
|
||||
table: "Settings");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "EnableLogMqttHealthPing",
|
||||
table: "Settings");
|
||||
}
|
||||
}
|
||||
}
|
||||
+151
@@ -0,0 +1,151 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using FinlyticAnalyzer.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 FinlyticAnalyzer.Migrations
|
||||
{
|
||||
[DbContext(typeof(AnalyzerDbContext))]
|
||||
[Migration("20260804184350_CheckPendingMigrations")]
|
||||
partial class CheckPendingMigrations
|
||||
{
|
||||
/// <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("FinlyticAnalyzer.Entities.AnalysisEntity", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("AiOutputJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("jsonb");
|
||||
|
||||
b.Property<string>("AnalysisId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("EventId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<double>("ImpactScore")
|
||||
.HasColumnType("double precision");
|
||||
|
||||
b.Property<bool>("IsTradeProposed")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("Isin")
|
||||
.IsRequired()
|
||||
.HasMaxLength(30)
|
||||
.HasColumnType("character varying(30)");
|
||||
|
||||
b.Property<string>("N8nDecision")
|
||||
.IsRequired()
|
||||
.HasMaxLength(30)
|
||||
.HasColumnType("character varying(30)");
|
||||
|
||||
b.Property<double>("N8nEvalScore")
|
||||
.HasColumnType("double precision");
|
||||
|
||||
b.Property<string>("N8nResponseJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("jsonb");
|
||||
|
||||
b.Property<string>("RawDataJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("jsonb");
|
||||
|
||||
b.Property<string>("Sector")
|
||||
.IsRequired()
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("character varying(50)");
|
||||
|
||||
b.Property<string>("Symbol")
|
||||
.IsRequired()
|
||||
.HasMaxLength(30)
|
||||
.HasColumnType("character varying(30)");
|
||||
|
||||
b.Property<int>("VixRegime")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<decimal>("VixValue")
|
||||
.HasColumnType("numeric");
|
||||
|
||||
b.Property<double>("WinRate")
|
||||
.HasColumnType("double precision");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("AnalysisId")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("CreatedAt");
|
||||
|
||||
b.HasIndex("EventId");
|
||||
|
||||
b.HasIndex("Isin");
|
||||
|
||||
b.HasIndex("Sector");
|
||||
|
||||
b.ToTable("analyses");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("FinlyticAnalyzer.Entities.AnalyzerSettingsEntity", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<bool>("EnableLogAnalyzerAuto")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<bool>("EnableLogAnalyzerManual")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<bool>("EnableLogDatabaseOps")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<bool>("EnableLogMqttGeneral")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<bool>("EnableLogMqttHealthPing")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<double>("MinSignalScore")
|
||||
.HasColumnType("double precision");
|
||||
|
||||
b.Property<string>("ScanCronSchedule")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("Settings");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace FinlyticAnalyzer.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class CheckPendingMigrations : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using FinlyticAnalyzer.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 FinlyticAnalyzer.Migrations
|
||||
{
|
||||
[DbContext(typeof(AnalyzerDbContext))]
|
||||
[Migration("20260805184638_AddTradeProposals")]
|
||||
partial class AddTradeProposals
|
||||
{
|
||||
/// <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("FinlyticAnalyzer.Entities.AnalysisEntity", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("AiOutputJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("jsonb");
|
||||
|
||||
b.Property<string>("AnalysisId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("EventId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<double>("ImpactScore")
|
||||
.HasColumnType("double precision");
|
||||
|
||||
b.Property<bool>("IsTradeProposed")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("Isin")
|
||||
.IsRequired()
|
||||
.HasMaxLength(30)
|
||||
.HasColumnType("character varying(30)");
|
||||
|
||||
b.Property<string>("N8nDecision")
|
||||
.IsRequired()
|
||||
.HasMaxLength(30)
|
||||
.HasColumnType("character varying(30)");
|
||||
|
||||
b.Property<double>("N8nEvalScore")
|
||||
.HasColumnType("double precision");
|
||||
|
||||
b.Property<string>("N8nResponseJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("jsonb");
|
||||
|
||||
b.Property<string>("RawDataJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("jsonb");
|
||||
|
||||
b.Property<string>("Sector")
|
||||
.IsRequired()
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("character varying(50)");
|
||||
|
||||
b.Property<string>("Symbol")
|
||||
.IsRequired()
|
||||
.HasMaxLength(30)
|
||||
.HasColumnType("character varying(30)");
|
||||
|
||||
b.Property<int>("VixRegime")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<decimal>("VixValue")
|
||||
.HasColumnType("numeric");
|
||||
|
||||
b.Property<double>("WinRate")
|
||||
.HasColumnType("double precision");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("AnalysisId")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("CreatedAt");
|
||||
|
||||
b.HasIndex("EventId");
|
||||
|
||||
b.HasIndex("Isin");
|
||||
|
||||
b.HasIndex("Sector");
|
||||
|
||||
b.ToTable("analyses");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("FinlyticAnalyzer.Entities.AnalyzerSettingsEntity", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<bool>("EnableLogAnalyzerAuto")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<bool>("EnableLogAnalyzerManual")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<bool>("EnableLogDatabaseOps")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<bool>("EnableLogMqttGeneral")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<bool>("EnableLogMqttHealthPing")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<double>("MinSignalScore")
|
||||
.HasColumnType("double precision");
|
||||
|
||||
b.Property<string>("ScanCronSchedule")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("Settings");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("FinlyticAnalyzer.Entities.TradeProposalEntity", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<double>("ConfidenceScore")
|
||||
.HasColumnType("double precision");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTime>("ExpiresAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Isin")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ProposedAction")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ReasonSummary")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<int>("Type")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ExpiresAt");
|
||||
|
||||
b.HasIndex("Isin");
|
||||
|
||||
b.ToTable("TradeProposals");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace FinlyticAnalyzer.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddTradeProposals : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "TradeProposals",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
Isin = table.Column<string>(type: "text", nullable: false),
|
||||
Name = table.Column<string>(type: "text", nullable: false),
|
||||
Type = table.Column<int>(type: "integer", nullable: false),
|
||||
ProposedAction = table.Column<string>(type: "text", nullable: false),
|
||||
ConfidenceScore = table.Column<double>(type: "double precision", nullable: false),
|
||||
ReasonSummary = table.Column<string>(type: "text", nullable: false),
|
||||
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||
ExpiresAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_TradeProposals", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_TradeProposals_ExpiresAt",
|
||||
table: "TradeProposals",
|
||||
column: "ExpiresAt");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_TradeProposals_Isin",
|
||||
table: "TradeProposals",
|
||||
column: "Isin");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "TradeProposals");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using FinlyticAnalyzer.Database;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace FinlyticAnalyzer.Migrations
|
||||
{
|
||||
[DbContext(typeof(AnalyzerDbContext))]
|
||||
partial class AnalyzerDbContextModelSnapshot : 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("FinlyticAnalyzer.Entities.AnalysisEntity", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("AiOutputJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("jsonb");
|
||||
|
||||
b.Property<string>("AnalysisId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("EventId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<double>("ImpactScore")
|
||||
.HasColumnType("double precision");
|
||||
|
||||
b.Property<bool>("IsTradeProposed")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("Isin")
|
||||
.IsRequired()
|
||||
.HasMaxLength(30)
|
||||
.HasColumnType("character varying(30)");
|
||||
|
||||
b.Property<string>("N8nDecision")
|
||||
.IsRequired()
|
||||
.HasMaxLength(30)
|
||||
.HasColumnType("character varying(30)");
|
||||
|
||||
b.Property<double>("N8nEvalScore")
|
||||
.HasColumnType("double precision");
|
||||
|
||||
b.Property<string>("N8nResponseJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("jsonb");
|
||||
|
||||
b.Property<string>("RawDataJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("jsonb");
|
||||
|
||||
b.Property<string>("Sector")
|
||||
.IsRequired()
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("character varying(50)");
|
||||
|
||||
b.Property<string>("Symbol")
|
||||
.IsRequired()
|
||||
.HasMaxLength(30)
|
||||
.HasColumnType("character varying(30)");
|
||||
|
||||
b.Property<int>("VixRegime")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<decimal>("VixValue")
|
||||
.HasColumnType("numeric");
|
||||
|
||||
b.Property<double>("WinRate")
|
||||
.HasColumnType("double precision");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("AnalysisId")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("CreatedAt");
|
||||
|
||||
b.HasIndex("EventId");
|
||||
|
||||
b.HasIndex("Isin");
|
||||
|
||||
b.HasIndex("Sector");
|
||||
|
||||
b.ToTable("analyses");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("FinlyticAnalyzer.Entities.AnalyzerSettingsEntity", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<bool>("EnableLogAnalyzerAuto")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<bool>("EnableLogAnalyzerManual")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<bool>("EnableLogDatabaseOps")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<bool>("EnableLogMqttGeneral")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<bool>("EnableLogMqttHealthPing")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<double>("MinSignalScore")
|
||||
.HasColumnType("double precision");
|
||||
|
||||
b.Property<string>("ScanCronSchedule")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("Settings");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("FinlyticAnalyzer.Entities.TradeProposalEntity", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<double>("ConfidenceScore")
|
||||
.HasColumnType("double precision");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTime>("ExpiresAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Isin")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ProposedAction")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ReasonSummary")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<int>("Type")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ExpiresAt");
|
||||
|
||||
b.HasIndex("Isin");
|
||||
|
||||
b.ToTable("TradeProposals");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
using System;
|
||||
using FinlyticAnalyzer.Database;
|
||||
using FinlyticAnalyzer.Services;
|
||||
using FinlyticAnalyzer.Util;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
var builder = Host.CreateApplicationBuilder(args);
|
||||
|
||||
// Register DB Context
|
||||
builder.Services.AddDbContext<AnalyzerDbContext>(options =>
|
||||
options.UseNpgsql(builder.Configuration.GetConnectionString("DefaultConnection")));
|
||||
|
||||
// Register HTTP Clients for external scrapers/webhooks
|
||||
builder.Services.AddHttpClient<IVixTrackerService, VixTrackerService>();
|
||||
builder.Services.AddHttpClient<IN8nEvaluationService, N8nEvaluationService>();
|
||||
|
||||
// Register Domain Services
|
||||
builder.Services.AddSingleton<IVixTrackerService, VixTrackerService>();
|
||||
builder.Services.AddSingleton<IThreeLayerFilterEngine, ThreeLayerFilterEngine>();
|
||||
builder.Services.AddSingleton<IWinRateCalculator, WinRateCalculator>();
|
||||
builder.Services.AddSingleton<IN8nEvaluationService, N8nEvaluationService>();
|
||||
builder.Services.AddScoped<ISettingsDbService, SettingsDbService>();
|
||||
|
||||
// Unified MQTT Client (Handles both Events and RPC)
|
||||
builder.Services.AddSingleton<AnalyzerMqttClient>();
|
||||
builder.Services.AddHostedService(provider => provider.GetRequiredService<AnalyzerMqttClient>());
|
||||
|
||||
// Register Active Trade Monitor
|
||||
builder.Services.AddHostedService<ActiveTradeMonitorWorker>();
|
||||
|
||||
var host = builder.Build();
|
||||
|
||||
// Run DB Migrations
|
||||
using (var scope = host.Services.CreateScope())
|
||||
{
|
||||
try
|
||||
{
|
||||
var context = scope.ServiceProvider.GetRequiredService<AnalyzerDbContext>();
|
||||
await context.Database.MigrateAsync();
|
||||
Console.WriteLine("Database migrations successfully executed for FinlyticAnalyzer.");
|
||||
|
||||
var settingsService = scope.ServiceProvider.GetRequiredService<ISettingsDbService>();
|
||||
await settingsService.GetSettingsAsync();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
var logger = scope.ServiceProvider.GetRequiredService<ILogger<Program>>();
|
||||
logger.LogError(ex, "An error occurred during database migration for FinlyticAnalyzer on startup.");
|
||||
}
|
||||
}
|
||||
|
||||
// Initial VIX Poll
|
||||
using (var scope = host.Services.CreateScope())
|
||||
{
|
||||
var vixService = scope.ServiceProvider.GetRequiredService<IVixTrackerService>();
|
||||
await vixService.PollVixAsync();
|
||||
}
|
||||
|
||||
await host.RunAsync();
|
||||
@@ -0,0 +1,37 @@
|
||||
# Finlytic Analyzer Service
|
||||
|
||||
Finlytic Analyzer is the core quantitative decision engine of the Finlytic ecosystem. It evaluates multi-layered market filters, tracks VIX volatility regimes, evaluates AI win rates, and generates actionable trade proposals.
|
||||
|
||||
---
|
||||
|
||||
## Core Features & Architecture
|
||||
|
||||
1. **3-Layer Filter Engine (`IThreeLayerFilterEngine`)**:
|
||||
- **Layer 1 (Macro VIX Regime)**: Evaluates overall volatility conditions via `IVixTrackerService`.
|
||||
- **Layer 2 (Asset Technical Analysis & Indicators)**: Evaluates RSI, MACD, Moving Averages, and Supertrend alignment.
|
||||
- **Layer 3 (AI Sentiment & Event Context)**: Evaluates FinBERT news sentiment scores and corporate earnings proximity.
|
||||
|
||||
2. **VIX Volatility Tracker (`IVixTrackerService`)**:
|
||||
- Polls external VIX volatility sources and categorizes market regimes (`Low`, `Normal`, `Elevated`, `High`).
|
||||
|
||||
3. **Win-Rate Calculator (`IWinRateCalculator`)**:
|
||||
- Calculates historical probability of success based on trade feedback records.
|
||||
|
||||
4. **MQTT Signal Publisher (`AnalyzerMqttClient`)**:
|
||||
- Publishes generated trade proposals to `finlytic/trades/proposed/{symbol}`.
|
||||
|
||||
---
|
||||
|
||||
## Feature Status
|
||||
|
||||
### Implemented Features
|
||||
- [x] 3-Layer Quantitative Filter Engine (`ThreeLayerFilterEngine`).
|
||||
- [x] VIX Volatility Regime Tracker (`VixTrackerService`).
|
||||
- [x] Win-Rate Probability Calculator (`WinRateCalculator`).
|
||||
- [x] n8n AI Evaluation Integration (`N8nEvaluationService`).
|
||||
- [x] Pure Worker Service Architecture (`Host.CreateApplicationBuilder`, Kestrel webserver removed).
|
||||
- [x] Zero-Allocation MQTT Signal Publishing (`AnalyzerMqttClient`).
|
||||
|
||||
### Planned Features
|
||||
- [ ] Multi-year historical Backtesting Engine with Monte Carlo simulation.
|
||||
- [ ] Portfolio Risk Allocation & Kelly Criterion Position Sizing Engine.
|
||||
@@ -0,0 +1,357 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using FinlyticAnalyzer.Util;
|
||||
using FinlyticCore.Dtos;
|
||||
using FinlyticCore.Dtos.TechnicalAnalysis;
|
||||
using FinlyticCore.Models.Analyzer;
|
||||
using FinlyticCore.Models.Trades;
|
||||
using FinlyticCore.Util;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace FinlyticAnalyzer.Services;
|
||||
|
||||
public class ActiveTradeMonitorWorker : BackgroundService
|
||||
{
|
||||
private readonly ILogger<ActiveTradeMonitorWorker> _logger;
|
||||
private readonly IServiceScopeFactory _scopeFactory;
|
||||
private readonly AnalyzerMqttClient _mqttClient;
|
||||
|
||||
public ActiveTradeMonitorWorker(ILogger<ActiveTradeMonitorWorker> logger, IServiceScopeFactory scopeFactory,
|
||||
AnalyzerMqttClient mqttClient)
|
||||
{
|
||||
_logger = logger;
|
||||
_scopeFactory = scopeFactory;
|
||||
_mqttClient = mqttClient;
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
_logger.LogInformation("[{Channel}] ActiveTradeMonitorWorker started.", "AnalyzerChannel");
|
||||
|
||||
try
|
||||
{
|
||||
await Task.Delay(TimeSpan.FromSeconds(30), stoppingToken);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
await MonitorActiveTradesAsync(stoppingToken);
|
||||
}
|
||||
catch (Exception ex) when (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
_logger.LogError(ex, "[{Channel}] Error in ActiveTradeMonitorWorker loop.", "AnalyzerChannel");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await Task.Delay(TimeSpan.FromMinutes(60), stoppingToken);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogInformation("[{Channel}] ActiveTradeMonitorWorker stopped.", "AnalyzerChannel");
|
||||
}
|
||||
|
||||
private async Task MonitorActiveTradesAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (!_mqttClient.IsConnected)
|
||||
{
|
||||
_logger.LogWarning("[{Channel}] Skipping trade monitoring. RPC client not connected.", "AnalyzerChannel");
|
||||
return;
|
||||
}
|
||||
|
||||
// Fetch active trades
|
||||
var activeTrades = await _mqttClient.SendRpcRequestAsync<List<TradeProposalDto>, GetTradesRequest>(
|
||||
"trades_Get",
|
||||
new GetTradesRequest(null, "Active"),
|
||||
TimeSpan.FromSeconds(10));
|
||||
|
||||
// Fetch proposed global trades
|
||||
var proposedTrades = await _mqttClient.SendRpcRequestAsync<List<TradeProposalDto>, GetTradesRequest>(
|
||||
"trades_Get",
|
||||
new GetTradesRequest(null, "Proposed"),
|
||||
TimeSpan.FromSeconds(10));
|
||||
|
||||
var trades = new List<TradeProposalDto>();
|
||||
if (activeTrades != null) trades.AddRange(activeTrades);
|
||||
if (proposedTrades != null) trades.AddRange(proposedTrades.Where(t => t.IsGlobalProposal));
|
||||
|
||||
if (trades.Count == 0)
|
||||
{
|
||||
_logger.LogInformation("[{Channel}] No active or proposed global trades found to monitor.",
|
||||
"AnalyzerChannel");
|
||||
return;
|
||||
}
|
||||
|
||||
_logger.LogInformation("[{Channel}] Found {Count} trades to monitor. Starting evaluation...", "AnalyzerChannel",
|
||||
trades.Count);
|
||||
|
||||
using var scope = _scopeFactory.CreateScope();
|
||||
var n8nService = scope.ServiceProvider.GetRequiredService<IN8nEvaluationService>();
|
||||
|
||||
foreach (var trade in trades)
|
||||
{
|
||||
if (cancellationToken.IsCancellationRequested) break;
|
||||
|
||||
try
|
||||
{
|
||||
await ProcessTradeAsync(trade, n8nService, cancellationToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "[{Channel}] Failed to monitor trade {TradeId} ({Symbol}).", "AnalyzerChannel",
|
||||
trade.TradeId, trade.Symbol);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ProcessTradeAsync(TradeProposalDto trade, IN8nEvaluationService n8nService,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. Get Live Price
|
||||
var livePriceReq = new IsinRequest(trade.Isin);
|
||||
var livePriceDto = await _mqttClient.SendRpcRequestAsync<LivePriceDto, IsinRequest>(
|
||||
"tr_GetLivePrice", livePriceReq, TimeSpan.FromSeconds(3));
|
||||
|
||||
decimal currentPrice = livePriceDto?.CurrentPrice > 0 ? livePriceDto.CurrentPrice : trade.EntryPrice;
|
||||
|
||||
// 2. Evaluate Hard Stops (StopLoss / TakeProfit / TimeStop)
|
||||
bool isLong = string.Equals(trade.SignalType, "BUY", StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(trade.SignalType, "LONG", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
// Time-Stop Evaluierung
|
||||
int maxHoldingDays = EstimateMaxHoldingDays(trade.Timeframe);
|
||||
double daysOpen = (DateTime.UtcNow - trade.CreatedAt).TotalDays;
|
||||
|
||||
// 50% Grace Period. Bei z.B. 10 Tagen max. Haltedauer wird nach 15 Tagen ohne Zielerreichung glattgestellt.
|
||||
if (daysOpen > (maxHoldingDays * 1.5))
|
||||
{
|
||||
await SendUpdateAsync(trade, currentPrice, "Close",
|
||||
$"Time-Stop getriggert: Setup ist invalidiert. Der Trade bewegt sich zu lange seitwärts (Offen seit {(int)daysOpen} Tagen, anvisiert waren max. {maxHoldingDays} Tage).");
|
||||
return;
|
||||
}
|
||||
|
||||
if (isLong)
|
||||
{
|
||||
if (trade.StopLoss > 0 && currentPrice <= trade.StopLoss)
|
||||
{
|
||||
await SendUpdateAsync(trade, currentPrice, "Close", "Hard Stop-Loss getriggert.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (trade.TakeProfit > 0 && currentPrice >= trade.TakeProfit)
|
||||
{
|
||||
await SendUpdateAsync(trade, currentPrice, "Close", "Hard Take-Profit erreicht.");
|
||||
return;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
if (trade.StopLoss > 0 && currentPrice >= trade.StopLoss)
|
||||
{
|
||||
await SendUpdateAsync(trade, currentPrice, "Close", "Hard Stop-Loss getriggert.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (trade.TakeProfit > 0 && currentPrice <= trade.TakeProfit)
|
||||
{
|
||||
await SendUpdateAsync(trade, currentPrice, "Close", "Hard Take-Profit erreicht.");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Run AI evaluation for soft/dynamic updates
|
||||
var taResult = await _mqttClient.SendRpcRequestAsync<TechnicalAnalysisDto, IsinRequest>(
|
||||
"ta_GetAnalysis", livePriceReq, TimeSpan.FromSeconds(5));
|
||||
|
||||
var latestIndicator = taResult?.Indicators?.LastOrDefault();
|
||||
|
||||
var taInfo = new TechnicalContextInfo
|
||||
{
|
||||
Rsi = latestIndicator?.Rsi14?.ToString("F1") ?? "N/A",
|
||||
SupertrendStatus = latestIndicator?.SupertrendDirection ?? "N/A",
|
||||
Atr = latestIndicator?.Atr14?.ToString("F2") ?? "N/A",
|
||||
Sma50 = (double?)latestIndicator?.Sma50,
|
||||
Sma200 = (double?)latestIndicator?.Sma200,
|
||||
DetectedPatterns = taResult?.Patterns?.Select(p => new PatternContextInfo
|
||||
{
|
||||
PatternName = p.Type,
|
||||
BreakoutDirection = p.BreakoutSignal?.Direction,
|
||||
TargetPrice = (double?)p.BreakoutSignal?.TargetPrice,
|
||||
PotentialPercent = (double?)p.BreakoutSignal?.PotentialPercent
|
||||
}).ToList() ?? new List<PatternContextInfo>()
|
||||
};
|
||||
|
||||
var n8nReq = new N8nAnalysisRequestDto
|
||||
{
|
||||
RequestId = Guid.NewGuid().ToString("N"),
|
||||
Timestamp = DateTime.UtcNow,
|
||||
TriggerType = "HourlyMonitor",
|
||||
TargetAsset = new TargetAssetInfo
|
||||
{
|
||||
Symbol = trade.Symbol,
|
||||
Isin = trade.Isin,
|
||||
Sector = trade.Sector
|
||||
},
|
||||
MarketContext = new MarketContextInfo
|
||||
{
|
||||
Vix = trade.VixValue,
|
||||
MarketRegime = trade.VixRegime.ToString()
|
||||
},
|
||||
UserPreferences = new UserPreferencesInfo
|
||||
{
|
||||
InstrumentType = trade.InstrumentType,
|
||||
TimeframeFormatted = trade.Timeframe
|
||||
},
|
||||
TechnicalContext = taInfo
|
||||
};
|
||||
|
||||
var aiResponse = await n8nService.EvaluateAssetAsync(n8nReq, cancellationToken);
|
||||
if (aiResponse == null)
|
||||
{
|
||||
_logger.LogWarning("[{Channel}] AI evaluation returned null for {TradeId}. Skipping update.",
|
||||
"AnalyzerChannel", trade.TradeId);
|
||||
return;
|
||||
}
|
||||
|
||||
string newRecommendation = "Hold";
|
||||
string reasoning = aiResponse.AiReasoning;
|
||||
decimal? newStopLoss = trade.StopLoss;
|
||||
decimal? newTakeProfit = trade.TakeProfit;
|
||||
|
||||
// Check for trend reversal
|
||||
bool aiSuggestsShort =
|
||||
string.Equals(aiResponse.SuggestedDirection, "Short", StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(aiResponse.SuggestedDirection, "Sell", StringComparison.OrdinalIgnoreCase);
|
||||
bool aiSuggestsLong =
|
||||
string.Equals(aiResponse.SuggestedDirection, "Long", StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(aiResponse.SuggestedDirection, "Buy", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
if ((isLong && aiSuggestsShort) || (!isLong && aiSuggestsLong))
|
||||
{
|
||||
newRecommendation = "Close";
|
||||
reasoning =
|
||||
$"Trendwende detektiert: KI empfiehlt {aiResponse.SuggestedDirection}, Trade ist aber {(isLong ? "Long" : "Short")}.";
|
||||
}
|
||||
else if (string.Equals(aiResponse.AiDecision, "Reject", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
newRecommendation = "Close";
|
||||
reasoning = $"Risiko zu hoch: KI empfiehlt Exit. ({aiResponse.AiReasoning})";
|
||||
}
|
||||
else if (aiResponse.ExecutionPlan != null)
|
||||
{
|
||||
// Ratchet / Trailing Logic: StopLoss darf das Risiko nicht vergrößern!
|
||||
if (aiResponse.ExecutionPlan.StopLoss > 0)
|
||||
{
|
||||
var proposedSl = aiResponse.ExecutionPlan.StopLoss;
|
||||
if (isLong)
|
||||
{
|
||||
// Bei Long darf der StopLoss nur NACH OBEN angepasst werden
|
||||
if (trade.StopLoss <= 0 || proposedSl > trade.StopLoss)
|
||||
{
|
||||
newStopLoss = proposedSl;
|
||||
if (proposedSl > trade.StopLoss && trade.StopLoss > 0) newRecommendation = "AdjustSL";
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// Bei Short darf der StopLoss nur NACH UNTEN angepasst werden
|
||||
if (trade.StopLoss <= 0 || proposedSl < trade.StopLoss)
|
||||
{
|
||||
newStopLoss = proposedSl;
|
||||
if (proposedSl < trade.StopLoss && trade.StopLoss > 0) newRecommendation = "AdjustSL";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (aiResponse.ExecutionPlan.TakeProfitTargets != null &&
|
||||
aiResponse.ExecutionPlan.TakeProfitTargets.Count > 0)
|
||||
{
|
||||
var proposedTp = aiResponse.ExecutionPlan.TakeProfitTargets[0];
|
||||
if (proposedTp > 0 && proposedTp != trade.TakeProfit)
|
||||
{
|
||||
newTakeProfit = proposedTp;
|
||||
if (newRecommendation == "Hold") newRecommendation = "AdjustTP";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await SendUpdateAsync(trade, currentPrice, newRecommendation, reasoning, newStopLoss, newTakeProfit);
|
||||
}
|
||||
|
||||
private async Task SendUpdateAsync(TradeProposalDto trade, decimal currentPrice, string recommendation,
|
||||
string reasoning, decimal? suggestedStopLoss = null, decimal? suggestedTakeProfit = null)
|
||||
{
|
||||
var update = new TradeHourlyUpdateDto
|
||||
{
|
||||
TradeId = trade.TradeId,
|
||||
Recommendation = recommendation,
|
||||
CurrentPrice = currentPrice,
|
||||
SuggestedStopLoss = suggestedStopLoss,
|
||||
SuggestedTakeProfit = suggestedTakeProfit,
|
||||
VixValue = trade.VixValue,
|
||||
Reasoning = reasoning,
|
||||
Timestamp = DateTime.UtcNow
|
||||
};
|
||||
|
||||
// Direktes Objekt-Publishing nutzen (ManagedMqttClient serialisiert typgerecht)
|
||||
string topic = $"finlytic/trades/updates/{trade.Isin}";
|
||||
await _mqttClient.PublishAsync(topic, update);
|
||||
|
||||
_logger.LogInformation(
|
||||
"[{Channel}] Published trade update for {TradeId} to topic {Topic}. Recommendation: {Rec}, Reasoning: {Reason}",
|
||||
"AnalyzerChannel", trade.TradeId, topic, recommendation, reasoning);
|
||||
}
|
||||
|
||||
private static int EstimateMaxHoldingDays(string timeframe)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(timeframe)) return 14; // Default
|
||||
|
||||
string tfLower = timeframe.ToLowerInvariant();
|
||||
int multiplier = 1;
|
||||
|
||||
if (tfLower.Contains("woche") || tfLower.Contains("week")) multiplier = 7;
|
||||
else if (tfLower.Contains("monat") || tfLower.Contains("month")) multiplier = 30;
|
||||
else if (tfLower.Contains("jahr") || tfLower.Contains("year")) multiplier = 365;
|
||||
|
||||
var numbers = new List<int>();
|
||||
string currentNum = "";
|
||||
|
||||
foreach (char c in timeframe)
|
||||
{
|
||||
if (char.IsDigit(c))
|
||||
{
|
||||
currentNum += c;
|
||||
}
|
||||
else if (currentNum.Length > 0)
|
||||
{
|
||||
if (int.TryParse(currentNum, out int n)) numbers.Add(n);
|
||||
currentNum = "";
|
||||
}
|
||||
}
|
||||
|
||||
if (currentNum.Length > 0 && int.TryParse(currentNum, out int lastN)) numbers.Add(lastN);
|
||||
|
||||
int maxNum = numbers.Count > 0 ? numbers.Max() : 14;
|
||||
|
||||
if (maxNum == 0) maxNum = 14;
|
||||
if (multiplier == 1 && maxNum < 3) maxNum = 3; // Mindestens 3 Tage Kulanz
|
||||
|
||||
return maxNum * multiplier;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using FinlyticCore.Models.Analyzer;
|
||||
|
||||
namespace FinlyticAnalyzer.Services;
|
||||
|
||||
public interface IN8nEvaluationService
|
||||
{
|
||||
/// <summary>
|
||||
/// Evaluates an asset asynchronously using N8n.
|
||||
/// </summary>
|
||||
Task<N8nAnalysisResponseDto?> EvaluateAssetAsync(N8nAnalysisRequestDto request, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
using FinlyticCore.Models.Analyzer;
|
||||
using FinlyticCore.Dtos.News;
|
||||
|
||||
namespace FinlyticAnalyzer.Services;
|
||||
|
||||
public class FilterResult
|
||||
{
|
||||
public bool Passed { get; set; }
|
||||
public string RejectReason { get; set; } = string.Empty;
|
||||
|
||||
public string Sector { get; set; } = string.Empty;
|
||||
public string Symbol { get; set; } = string.Empty;
|
||||
public string Isin { get; set; } = string.Empty;
|
||||
|
||||
public double ImpactScore { get; set; }
|
||||
public double ThresholdApplied { get; set; }
|
||||
|
||||
public string RiskTolerance { get; set; } = "Moderate";
|
||||
public string Timeframe { get; set; } = "1D";
|
||||
public string InstrumentType { get; set; } = "Stock";
|
||||
}
|
||||
|
||||
public interface IThreeLayerFilterEngine
|
||||
{
|
||||
/// <summary>
|
||||
/// Evaluates news based on market regime and returns a filter result.
|
||||
/// </summary>
|
||||
FilterResult EvaluateNews(NewsArticleDto newsEvent, VixMarketRegime regime);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using FinlyticCore.Models.Analyzer;
|
||||
|
||||
namespace FinlyticAnalyzer.Services;
|
||||
|
||||
public interface IVixTrackerService
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the current VIX value.
|
||||
/// </summary>
|
||||
decimal GetCurrentVix();
|
||||
|
||||
/// <summary>
|
||||
/// Gets the current market regime based on VIX.
|
||||
/// </summary>
|
||||
VixMarketRegime GetCurrentRegime();
|
||||
|
||||
/// <summary>
|
||||
/// Updates the VIX tracker with a new tick value.
|
||||
/// </summary>
|
||||
void UpdateVixFromTick(decimal vixValue);
|
||||
|
||||
/// <summary>
|
||||
/// Polls the VIX asynchronously and returns its value.
|
||||
/// </summary>
|
||||
Task<decimal> PollVixAsync(CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
using FinlyticCore.Models.Analyzer;
|
||||
|
||||
namespace FinlyticAnalyzer.Services;
|
||||
|
||||
public interface IWinRateCalculator
|
||||
{
|
||||
/// <summary>
|
||||
/// Calculates the win rate for a given sector and symbol under the specified market regime.
|
||||
/// </summary>
|
||||
double CalculateWinRate(string sector, string symbol, VixMarketRegime regime);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
namespace FinlyticAnalyzer.Services;
|
||||
|
||||
public enum LogCategory
|
||||
{
|
||||
MqttHealthPing,
|
||||
MqttGeneral,
|
||||
AnalyzerAuto,
|
||||
AnalyzerManual,
|
||||
DatabaseOps,
|
||||
General
|
||||
}
|
||||
|
||||
public static class LogCategoryFilter
|
||||
{
|
||||
public static bool EnableLogMqttHealthPing { get; set; } = false;
|
||||
public static bool EnableLogMqttGeneral { get; set; } = true;
|
||||
public static bool EnableLogAnalyzerAuto { get; set; } = true;
|
||||
public static bool EnableLogAnalyzerManual { get; set; } = true;
|
||||
public static bool EnableLogDatabaseOps { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Checks if a given log category is enabled.
|
||||
/// </summary>
|
||||
public static bool IsEnabled(LogCategory category)
|
||||
{
|
||||
return category switch
|
||||
{
|
||||
LogCategory.MqttHealthPing => EnableLogMqttHealthPing,
|
||||
LogCategory.MqttGeneral => EnableLogMqttGeneral,
|
||||
LogCategory.AnalyzerAuto => EnableLogAnalyzerAuto,
|
||||
LogCategory.AnalyzerManual => EnableLogAnalyzerManual,
|
||||
LogCategory.DatabaseOps => EnableLogDatabaseOps,
|
||||
_ => true
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
using System.Text.Json;
|
||||
using FinlyticCore.Models.Analyzer;
|
||||
using FinlyticCore.Util;
|
||||
|
||||
namespace FinlyticAnalyzer.Services;
|
||||
|
||||
public class N8nEvaluationService : IN8nEvaluationService
|
||||
{
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly ILogger<N8nEvaluationService> _logger;
|
||||
private readonly string _webhookUrl;
|
||||
|
||||
public N8nEvaluationService(HttpClient httpClient, IConfiguration configuration, ILogger<N8nEvaluationService> logger)
|
||||
{
|
||||
_httpClient = httpClient;
|
||||
_logger = logger;
|
||||
_webhookUrl = configuration["N8N:WebhookUrl"] ?? configuration["N8N__WebhookUrl"] ?? "https://n8n.kleidukos.me/webhook/gemini/analysis/auto";
|
||||
|
||||
// Timeout auf 45 Sekunden erhöht für komplexere LLM/Gemini Chains in n8n
|
||||
_httpClient.Timeout = TimeSpan.FromSeconds(45);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Evaluates an asset asynchronously using N8n / Gemini workflows.
|
||||
/// </summary>
|
||||
public async Task<N8nAnalysisResponseDto?> EvaluateAssetAsync(N8nAnalysisRequestDto request, CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
_logger.LogInformation("[{Channel}] Sending n8n AI Evaluation request {RequestId} for Asset {Symbol} (ISIN: {Isin}) to {Url}...",
|
||||
"AnalyzerChannel", request.RequestId, request.TargetAsset.Symbol, request.TargetAsset.Isin, _webhookUrl);
|
||||
|
||||
// Typsichere AOT-Serialisierung verwenden
|
||||
using var content = JsonContent.Create(
|
||||
request,
|
||||
FinlyticJsonSerializerContext.Default.N8nAnalysisRequestDto);
|
||||
|
||||
using var response = await _httpClient.PostAsync(_webhookUrl, content, cancellationToken);
|
||||
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
var contentStr = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(contentStr) || contentStr.Trim() == "{}" || contentStr.Trim() == "[]")
|
||||
{
|
||||
_logger.LogWarning("[{Channel}] n8n Webhook returned an EMPTY response for Request {RequestId}. Flagging as AI Rejection (Too Risky).", "AnalyzerChannel", request.RequestId);
|
||||
return CreateRejectionFallback(request, "Die KI (n8n/Gemini) stuft den Trade als zu riskant ein und empfiehlt keine Positionierung.");
|
||||
}
|
||||
|
||||
// N8n schickt Ergebnisse manchmal als JSON-Array [{...}] zurück
|
||||
string jsonToDeserialize = contentStr.Trim();
|
||||
if (jsonToDeserialize.StartsWith('[') && jsonToDeserialize.EndsWith(']'))
|
||||
{
|
||||
using var doc = JsonDocument.Parse(jsonToDeserialize);
|
||||
if (doc.RootElement.ValueKind == JsonValueKind.Array && doc.RootElement.GetArrayLength() > 0)
|
||||
{
|
||||
jsonToDeserialize = doc.RootElement[0].GetRawText();
|
||||
}
|
||||
}
|
||||
|
||||
var responseDto = JsonSerializer.Deserialize(
|
||||
jsonToDeserialize,
|
||||
FinlyticJsonSerializerContext.Default.N8nAnalysisResponseDto);
|
||||
|
||||
if (responseDto != null && !string.IsNullOrWhiteSpace(responseDto.AiDecision))
|
||||
{
|
||||
_logger.LogInformation("[{Channel}] Received n8n AI Response for Request {RequestId}: Decision={Decision}, Score={Score:F2}, Direction={Direction}, Timeframe={Timeframe}",
|
||||
"AnalyzerChannel", request.RequestId, responseDto.AiDecision, responseDto.EvalScore, responseDto.SuggestedDirection, responseDto.SuggestedTimeframe);
|
||||
|
||||
return responseDto;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning("[{Channel}] n8n Webhook returned HTTP {StatusCode} for Request {RequestId}",
|
||||
"AnalyzerChannel", response.StatusCode, request.RequestId);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException ex) when (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
_logger.LogError(ex, "[{Channel}] Timeout waiting for n8n AI Evaluation Webhook (Request {RequestId}). Consider increasing timeout.", "AnalyzerChannel", request.RequestId);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "[{Channel}] Error calling n8n AI Evaluation Webhook for Request {RequestId}", "AnalyzerChannel", request.RequestId);
|
||||
}
|
||||
|
||||
return null; // Signals RPC/Service failure to caller
|
||||
}
|
||||
|
||||
private static N8nAnalysisResponseDto CreateRejectionFallback(N8nAnalysisRequestDto request, string reasoning)
|
||||
{
|
||||
return new N8nAnalysisResponseDto
|
||||
{
|
||||
RequestId = request.RequestId,
|
||||
AiDecision = "Rejected",
|
||||
EvalScore = 0.0,
|
||||
SuggestedDirection = "NONE",
|
||||
SuggestedRisk = request.UserPreferences?.RiskTolerance ?? "Moderate",
|
||||
SuggestedTimeframe = request.UserPreferences?.TimeframeFormatted ?? "1D",
|
||||
AiReasoning = reasoning
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
using FinlyticAnalyzer.Database;
|
||||
using FinlyticAnalyzer.Entities;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace FinlyticAnalyzer.Services;
|
||||
|
||||
public interface ISettingsDbService
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the analyzer settings asynchronously.
|
||||
/// </summary>
|
||||
Task<AnalyzerSettingsEntity> GetSettingsAsync();
|
||||
|
||||
/// <summary>
|
||||
/// Saves the analyzer settings asynchronously.
|
||||
/// </summary>
|
||||
Task<AnalyzerSettingsEntity> SaveSettingsAsync(AnalyzerSettingsEntity settings);
|
||||
|
||||
/// <summary>
|
||||
/// Updates settings from a dictionary asynchronously.
|
||||
/// </summary>
|
||||
Task UpdateSettingsFromDictionaryAsync(Dictionary<string, string> dictionary);
|
||||
}
|
||||
|
||||
public class SettingsDbService : ISettingsDbService
|
||||
{
|
||||
private readonly AnalyzerDbContext _context;
|
||||
|
||||
public SettingsDbService(AnalyzerDbContext context)
|
||||
{
|
||||
_context = context;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the analyzer settings asynchronously.
|
||||
/// </summary>
|
||||
public async Task<AnalyzerSettingsEntity> GetSettingsAsync()
|
||||
{
|
||||
var settings = await _context.Settings.AsNoTracking().FirstOrDefaultAsync();
|
||||
if (settings == null)
|
||||
{
|
||||
settings = new AnalyzerSettingsEntity { Id = Guid.NewGuid() };
|
||||
_context.Settings.Add(settings);
|
||||
await _context.SaveChangesAsync();
|
||||
_context.ChangeTracker.Clear();
|
||||
}
|
||||
return settings;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Saves the analyzer settings asynchronously.
|
||||
/// </summary>
|
||||
public async Task<AnalyzerSettingsEntity> SaveSettingsAsync(AnalyzerSettingsEntity settings)
|
||||
{
|
||||
var existing = await _context.Settings.FirstOrDefaultAsync();
|
||||
if (existing == null)
|
||||
{
|
||||
if (settings.Id == Guid.Empty) settings.Id = Guid.NewGuid();
|
||||
_context.Settings.Add(settings);
|
||||
}
|
||||
else
|
||||
{
|
||||
existing.ScanCronSchedule = settings.ScanCronSchedule;
|
||||
existing.MinSignalScore = settings.MinSignalScore;
|
||||
existing.EnableLogMqttHealthPing = settings.EnableLogMqttHealthPing;
|
||||
existing.EnableLogMqttGeneral = settings.EnableLogMqttGeneral;
|
||||
existing.EnableLogAnalyzerAuto = settings.EnableLogAnalyzerAuto;
|
||||
existing.EnableLogAnalyzerManual = settings.EnableLogAnalyzerManual;
|
||||
existing.EnableLogDatabaseOps = settings.EnableLogDatabaseOps;
|
||||
existing.UpdatedAt = settings.UpdatedAt;
|
||||
_context.Settings.Update(existing);
|
||||
}
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
// Synchronize in-memory static filter values
|
||||
LogCategoryFilter.EnableLogMqttHealthPing = settings.EnableLogMqttHealthPing;
|
||||
LogCategoryFilter.EnableLogMqttGeneral = settings.EnableLogMqttGeneral;
|
||||
LogCategoryFilter.EnableLogAnalyzerAuto = settings.EnableLogAnalyzerAuto;
|
||||
LogCategoryFilter.EnableLogAnalyzerManual = settings.EnableLogAnalyzerManual;
|
||||
LogCategoryFilter.EnableLogDatabaseOps = settings.EnableLogDatabaseOps;
|
||||
|
||||
return settings;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates settings from a dictionary asynchronously.
|
||||
/// </summary>
|
||||
public async Task UpdateSettingsFromDictionaryAsync(Dictionary<string, string> dictionary)
|
||||
{
|
||||
var settings = await GetSettingsAsync();
|
||||
|
||||
foreach (var (key, value) in dictionary)
|
||||
{
|
||||
if (string.Equals(key, "ScanCronSchedule", StringComparison.OrdinalIgnoreCase) && !string.IsNullOrWhiteSpace(value))
|
||||
settings.ScanCronSchedule = value.Trim();
|
||||
else if (string.Equals(key, "MinSignalScore", StringComparison.OrdinalIgnoreCase) && double.TryParse(value, out var score))
|
||||
settings.MinSignalScore = score;
|
||||
else if (string.Equals(key, "EnableLog_MqttHealthPing", StringComparison.OrdinalIgnoreCase) && bool.TryParse(value, out var b1))
|
||||
settings.EnableLogMqttHealthPing = b1;
|
||||
else if (string.Equals(key, "EnableLog_MqttGeneral", StringComparison.OrdinalIgnoreCase) && bool.TryParse(value, out var b2))
|
||||
settings.EnableLogMqttGeneral = b2;
|
||||
else if (string.Equals(key, "EnableLog_AnalyzerAuto", StringComparison.OrdinalIgnoreCase) && bool.TryParse(value, out var b3))
|
||||
settings.EnableLogAnalyzerAuto = b3;
|
||||
else if (string.Equals(key, "EnableLog_AnalyzerManual", StringComparison.OrdinalIgnoreCase) && bool.TryParse(value, out var b4))
|
||||
settings.EnableLogAnalyzerManual = b4;
|
||||
else if (string.Equals(key, "EnableLog_DatabaseOps", StringComparison.OrdinalIgnoreCase) && bool.TryParse(value, out var b5))
|
||||
settings.EnableLogDatabaseOps = b5;
|
||||
}
|
||||
|
||||
settings.UpdatedAt = DateTime.UtcNow;
|
||||
await SaveSettingsAsync(settings);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using FinlyticCore.Dtos.News;
|
||||
using FinlyticCore.Models.Analyzer;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace FinlyticAnalyzer.Services;
|
||||
|
||||
public class ThreeLayerFilterEngine : IThreeLayerFilterEngine
|
||||
{
|
||||
private readonly ILogger<ThreeLayerFilterEngine> _logger;
|
||||
private readonly ConcurrentDictionary<string, DateTime> _seenEvents = new();
|
||||
private DateTime _lastCleanupTime = DateTime.UtcNow;
|
||||
|
||||
public ThreeLayerFilterEngine(ILogger<ThreeLayerFilterEngine> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Evaluates news strictly based on ISIN and dynamic VIX market regime.
|
||||
/// </summary>
|
||||
public FilterResult EvaluateNews(NewsArticleDto newsEvent, VixMarketRegime regime)
|
||||
{
|
||||
var result = new FilterResult();
|
||||
|
||||
// -------------------------------------------------------------
|
||||
// Layer 1: Relevance, ISIN & Deduplication
|
||||
// -------------------------------------------------------------
|
||||
if (newsEvent == null || newsEvent.Id == Guid.Empty)
|
||||
{
|
||||
result.Passed = false;
|
||||
result.RejectReason = "Layer 1: Missing or Empty NewsArticle / EventId";
|
||||
return result;
|
||||
}
|
||||
|
||||
string eventId = newsEvent.Id.ToString();
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
// Safely clean up dictionary every 30 minutes
|
||||
if ((now - _lastCleanupTime).TotalMinutes > 30 || _seenEvents.Count > 10000)
|
||||
{
|
||||
CleanupSeenEvents(now);
|
||||
}
|
||||
|
||||
// Deduplication check (keep history for 12 hours)
|
||||
if (_seenEvents.TryGetValue(eventId, out var prevTime) && (now - prevTime).TotalHours < 12.0)
|
||||
{
|
||||
result.Passed = false;
|
||||
result.RejectReason = "Layer 1: Duplicate EventId within 12h window";
|
||||
return result;
|
||||
}
|
||||
|
||||
_seenEvents[eventId] = now;
|
||||
|
||||
string isin = string.Empty;
|
||||
string assetName = string.Empty;
|
||||
|
||||
// Extract parameters strictly from MatchedAssets
|
||||
if (newsEvent.MatchedAssets != null && newsEvent.MatchedAssets.Count > 0)
|
||||
{
|
||||
var firstAsset = newsEvent.MatchedAssets[0];
|
||||
isin = !string.IsNullOrWhiteSpace(firstAsset.Isin) ? firstAsset.Isin.Trim().ToUpperInvariant() : string.Empty;
|
||||
assetName = !string.IsNullOrWhiteSpace(firstAsset.Name) ? firstAsset.Name.Trim() : string.Empty;
|
||||
}
|
||||
|
||||
// Mandatory check: Must have a valid ISIN
|
||||
if (string.IsNullOrWhiteSpace(isin))
|
||||
{
|
||||
result.Passed = false;
|
||||
result.RejectReason = "Layer 1: Missing mandatory ISIN for news item";
|
||||
return result;
|
||||
}
|
||||
|
||||
result.Isin = isin;
|
||||
// Asset-Symbol fallback to ISIN, Name is mapped appropriately later
|
||||
result.Symbol = isin;
|
||||
result.Sector = "General"; // Will be enriched downstream via Fundamentals RPC if available
|
||||
|
||||
// -------------------------------------------------------------
|
||||
// Layer 2: Impact & Dynamic VIX Threshold
|
||||
// -------------------------------------------------------------
|
||||
double impactScore = newsEvent.Confidence ?? 0.75;
|
||||
if (impactScore <= 0) impactScore = 0.75;
|
||||
|
||||
double requiredThreshold = regime switch
|
||||
{
|
||||
VixMarketRegime.LowVol => 0.55,
|
||||
VixMarketRegime.Normal => 0.65,
|
||||
VixMarketRegime.HighVol => 0.80,
|
||||
VixMarketRegime.Panic => 0.90,
|
||||
_ => 0.65
|
||||
};
|
||||
|
||||
result.ImpactScore = impactScore;
|
||||
result.ThresholdApplied = requiredThreshold;
|
||||
|
||||
if (impactScore < requiredThreshold)
|
||||
{
|
||||
result.Passed = false;
|
||||
result.RejectReason = $"Layer 2: Impact score ({impactScore:F2}) below dynamic VIX threshold ({requiredThreshold:F2}) for regime {regime}";
|
||||
_logger.LogInformation("[{Channel}] Event {EventId} (ISIN: {Isin}) rejected by Layer 2 filter. Impact: {Impact:F2}, Threshold: {Threshold:F2}, Regime: {Regime}",
|
||||
"AnalyzerChannel", eventId, isin, impactScore, requiredThreshold, regime);
|
||||
return result;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------
|
||||
// Layer 3: Dynamic Parameter & Risk Engine
|
||||
// -------------------------------------------------------------
|
||||
result.RiskTolerance = regime switch
|
||||
{
|
||||
VixMarketRegime.Panic => "Conservative",
|
||||
VixMarketRegime.HighVol => "Moderate",
|
||||
_ => "Aggressive"
|
||||
};
|
||||
|
||||
result.Timeframe = impactScore >= 0.85 ? "4H" : "1D";
|
||||
result.InstrumentType = regime == VixMarketRegime.Panic ? "Option" : "Stock";
|
||||
|
||||
result.Passed = true;
|
||||
_logger.LogInformation("[{Channel}] Event {EventId} passed 3-Layer Filter for ISIN {Isin}. Impact: {Impact:F2}, Regime: {Regime}",
|
||||
"AnalyzerChannel", eventId, result.Isin, impactScore, regime);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private void CleanupSeenEvents(DateTime now)
|
||||
{
|
||||
_lastCleanupTime = now;
|
||||
foreach (var kv in _seenEvents)
|
||||
{
|
||||
if ((now - kv.Value).TotalHours > 12.0)
|
||||
{
|
||||
_seenEvents.TryRemove(kv.Key, out _);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using FinlyticCore.Models.Analyzer;
|
||||
using FinlyticCore.Services.Yahoo;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace FinlyticAnalyzer.Services;
|
||||
|
||||
public class VixTrackerService : IVixTrackerService
|
||||
{
|
||||
private readonly YahooFinanceClient _yahooClient;
|
||||
private readonly ILogger<VixTrackerService> _logger;
|
||||
|
||||
private decimal _currentVix = 18.5m; // Default: Normal Regime
|
||||
private VixMarketRegime _currentRegime = VixMarketRegime.Normal;
|
||||
private readonly object _lock = new();
|
||||
|
||||
public VixTrackerService(YahooFinanceClient yahooClient, ILogger<VixTrackerService> logger)
|
||||
{
|
||||
_yahooClient = yahooClient;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public decimal GetCurrentVix()
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
return _currentVix;
|
||||
}
|
||||
}
|
||||
|
||||
public VixMarketRegime GetCurrentRegime()
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
return _currentRegime;
|
||||
}
|
||||
}
|
||||
|
||||
public void UpdateVixFromTick(decimal vixValue)
|
||||
{
|
||||
if (vixValue <= 0m) return;
|
||||
|
||||
lock (_lock)
|
||||
{
|
||||
var oldRegime = _currentRegime;
|
||||
var oldVix = _currentVix;
|
||||
|
||||
_currentVix = vixValue;
|
||||
_currentRegime = CalculateRegime(vixValue);
|
||||
|
||||
if (oldRegime != _currentRegime)
|
||||
{
|
||||
_logger.LogWarning("[{Channel}] [VIX REGIME SHIFT] Markt-Regime gewechselt: {OldRegime} -> {NewRegime} (VIX: {Vix:F2})",
|
||||
"AnalyzerChannel", oldRegime, _currentRegime, _currentVix);
|
||||
}
|
||||
else if (Math.Abs(oldVix - vixValue) >= 0.5m)
|
||||
{
|
||||
_logger.LogInformation("[{Channel}] VIX aktualisiert: {Vix:F2} (Regime: {Regime})",
|
||||
"AnalyzerChannel", _currentVix, _currentRegime);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<decimal> PollVixAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var vix = await _yahooClient.GetLivePriceAsync("^VIX", cancellationToken);
|
||||
|
||||
if (vix.HasValue && vix.Value > 0m)
|
||||
{
|
||||
UpdateVixFromTick(vix.Value);
|
||||
return vix.Value;
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
// Graceful shutdown
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "[{Channel}] Fehler beim Abfragen von ^VIX über YahooFinanceClient. Nutze gecachten Wert {Vix}.",
|
||||
"AnalyzerChannel", GetCurrentVix());
|
||||
}
|
||||
|
||||
return GetCurrentVix();
|
||||
}
|
||||
|
||||
private static VixMarketRegime CalculateRegime(decimal vix)
|
||||
{
|
||||
return vix switch
|
||||
{
|
||||
< 15.0m => VixMarketRegime.LowVol,
|
||||
>= 15.0m and < 20.0m => VixMarketRegime.Normal,
|
||||
>= 20.0m and < 30.0m => VixMarketRegime.HighVol,
|
||||
_ => VixMarketRegime.Panic
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using FinlyticCore.Models.Analyzer;
|
||||
using FinlyticCore.Models.Trades;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace FinlyticAnalyzer.Services;
|
||||
|
||||
public class WinRateCalculator : IWinRateCalculator
|
||||
{
|
||||
private readonly ILogger<WinRateCalculator> _logger;
|
||||
private readonly string _feedbackDir;
|
||||
|
||||
public WinRateCalculator(ILogger<WinRateCalculator> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
_feedbackDir = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "data", "feedback");
|
||||
if (!Directory.Exists(_feedbackDir))
|
||||
{
|
||||
Directory.CreateDirectory(_feedbackDir);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Calculates the win rate for a given sector and symbol under the specified market regime.
|
||||
/// </summary>
|
||||
public double CalculateWinRate(string sector, string symbol, VixMarketRegime regime)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!Directory.Exists(_feedbackDir)) return 65.0;
|
||||
|
||||
var jsonFiles = Directory.GetFiles(_feedbackDir, "*.json", SearchOption.AllDirectories);
|
||||
if (jsonFiles.Length == 0) return 65.0;
|
||||
|
||||
int totalTrades = 0;
|
||||
int winningTrades = 0;
|
||||
|
||||
foreach (var file in jsonFiles)
|
||||
{
|
||||
var content = File.ReadAllText(file);
|
||||
var records = JsonSerializer.Deserialize<TradeFeedbackRecord[]>(content);
|
||||
if (records == null || records.Length == 0) continue;
|
||||
|
||||
var matching = records.Where(r =>
|
||||
string.Equals(r.Sector, sector, StringComparison.OrdinalIgnoreCase) &&
|
||||
r.VixRegime == regime).ToList();
|
||||
|
||||
foreach (var rec in matching)
|
||||
{
|
||||
totalTrades++;
|
||||
if (rec.IsWin) winningTrades++;
|
||||
}
|
||||
}
|
||||
|
||||
if (totalTrades > 0)
|
||||
{
|
||||
double calculatedWinRate = (double)winningTrades / totalTrades * 100.0;
|
||||
_logger.LogInformation("[{Channel}] Calculated win-rate for Sector '{Sector}' in Regime '{Regime}': {WinRate:F1}% ({Wins}/{Total})",
|
||||
"AnalyzerChannel", sector, regime, calculatedWinRate, winningTrades, totalTrades);
|
||||
return Math.Round(calculatedWinRate, 1);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "[{Channel}] Error reading feedback files for win-rate calculation. Falling back to default.", "AnalyzerChannel");
|
||||
}
|
||||
|
||||
return 65.0; // Default baseline win-rate
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,804 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using FinlyticAnalyzer.Database;
|
||||
using FinlyticAnalyzer.Entities;
|
||||
using FinlyticAnalyzer.Services;
|
||||
using FinlyticCore.Dtos;
|
||||
using FinlyticCore.Models;
|
||||
using FinlyticCore.Models.Analyzer;
|
||||
using FinlyticCore.Models.Trades;
|
||||
using FinlyticCore.Util;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace FinlyticAnalyzer.Util;
|
||||
|
||||
/// <summary>
|
||||
/// Unified Managed MQTT Client for FinlyticAnalyzer.
|
||||
/// Handles event subscriptions, market screening, manual AI evaluation triggers,
|
||||
/// and dispatches trade proposals via MQTT.
|
||||
/// </summary>
|
||||
public class AnalyzerMqttClient : ManagedMqttClient, IHostedService
|
||||
{
|
||||
private readonly IConfiguration _configuration;
|
||||
private readonly IServiceScopeFactory _scopeFactory;
|
||||
private readonly IVixTrackerService _vixTracker;
|
||||
private readonly IThreeLayerFilterEngine _filterEngine;
|
||||
private readonly IWinRateCalculator _winRateCalculator;
|
||||
private readonly IN8nEvaluationService _n8nService;
|
||||
private readonly ILogger<AnalyzerMqttClient> _logger;
|
||||
|
||||
public AnalyzerMqttClient(
|
||||
IConfiguration configuration,
|
||||
IServiceScopeFactory scopeFactory,
|
||||
IVixTrackerService vixTracker,
|
||||
IThreeLayerFilterEngine filterEngine,
|
||||
IWinRateCalculator winRateCalculator,
|
||||
IN8nEvaluationService n8nService,
|
||||
ILogger<AnalyzerMqttClient> logger) : base(logger)
|
||||
{
|
||||
_configuration = configuration;
|
||||
_scopeFactory = scopeFactory;
|
||||
_vixTracker = vixTracker;
|
||||
_filterEngine = filterEngine;
|
||||
_winRateCalculator = winRateCalculator;
|
||||
_n8nService = n8nService;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task StartAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var config = new MqttConfiguration
|
||||
{
|
||||
Host = _configuration["MQTT:Host"] ?? _configuration["MQTT__Host"] ?? "localhost",
|
||||
Port = Convert.ToInt32(_configuration["MQTT:Port"] ?? _configuration["MQTT__Port"] ?? "1883"),
|
||||
Username = _configuration["MQTT:Username"] ?? _configuration["MQTT__Username"],
|
||||
Password = _configuration["MQTT:Password"] ?? _configuration["MQTT__Password"],
|
||||
ClientId = $"{(_configuration["MQTT:ClientId"] ?? _configuration["MQTT__ClientId"] ?? "finlytic_analyzer")}_{Guid.NewGuid():N}"
|
||||
};
|
||||
|
||||
_logger.LogInformation("[{Channel}] Starting Unified Analyzer MQTT Client. Host: {Host}, ClientId: {ClientId}", "AnalyzerChannel", config.Host, config.ClientId);
|
||||
await ConnectAsync(config);
|
||||
}
|
||||
|
||||
public async Task StopAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
_logger.LogInformation("[{Channel}] Stopping Unified Analyzer MQTT Client.", "AnalyzerChannel");
|
||||
await DisconnectAsync();
|
||||
}
|
||||
|
||||
protected override async Task OnConnectedAsync()
|
||||
{
|
||||
_logger.LogInformation("[{Channel}] Analyzer MQTT Client connected. Subscribing to topics and RPC response channels...", "AnalyzerChannel");
|
||||
|
||||
// Incoming Event Topics
|
||||
await SubscribeAsync("services/news/completed");
|
||||
await SubscribeAsync("services/news/#");
|
||||
await SubscribeAsync("finlytic/news/raw/#");
|
||||
await SubscribeAsync("finlytic/market/ticks/#");
|
||||
await SubscribeAsync("services/config/updated/#");
|
||||
await SubscribeAsync("services/request/health_Ping/#");
|
||||
await SubscribeAsync("services/request/analyzer_TriggerManual/#");
|
||||
await SubscribeAsync("finlytic/trades/closed/#");
|
||||
|
||||
// RPC Response Channels
|
||||
await SubscribeAsync("services/response/ta_GetAnalysis/#");
|
||||
await SubscribeAsync("services/response/fundamentals_Get/#");
|
||||
await SubscribeAsync("services/response/sentiment_GetIsin/#");
|
||||
await SubscribeAsync("services/response/trades_Get/#");
|
||||
await SubscribeAsync("services/response/tr_GetLivePrice/#");
|
||||
|
||||
_logger.LogInformation("[{Channel}] Successfully subscribed to all event and RPC channels.", "AnalyzerChannel");
|
||||
}
|
||||
|
||||
protected override async Task OnMessageReceivedAsync(string topic, string payloadStr)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (topic.Contains("health_Ping", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var segments = topic.Split('/');
|
||||
bool isForMe = segments.Length >= 5
|
||||
? segments[3].Equals("FinlyticAnalyzer", StringComparison.OrdinalIgnoreCase)
|
||||
: topic.Contains("FinlyticAnalyzer", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
if (isForMe)
|
||||
{
|
||||
var correlationId = segments[^1];
|
||||
string respTopic = $"services/response/health_Ping/{correlationId}";
|
||||
var healthResp = new ServiceHealthResponse("FinlyticAnalyzer", "Online", DateTime.UtcNow, "Connected");
|
||||
await PublishAsync(respTopic, healthResp);
|
||||
if (LogCategoryFilter.IsEnabled(LogCategory.MqttHealthPing))
|
||||
{
|
||||
_logger.LogInformation("[{Channel}] [AnalyzerMqttClient] Responded to live health_Ping RPC request [CorrelationId: {CorrelationId}].", "AnalyzerChannel", correlationId);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (topic.StartsWith("services/config/updated", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
if (topic.EndsWith("FinlyticAnalyzer", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
_logger.LogInformation("[{Channel}] [AnalyzerMqttClient] Received config update event for FinlyticAnalyzer.", "AnalyzerChannel");
|
||||
try
|
||||
{
|
||||
var configUpdate = JsonSerializer.Deserialize(payloadStr, FinlyticJsonSerializerContext.Default.ServiceConfigUpdatePayload);
|
||||
if (configUpdate?.Settings != null && configUpdate.Settings.Count > 0)
|
||||
{
|
||||
using var scope = _scopeFactory.CreateScope();
|
||||
var settingsDb = scope.ServiceProvider.GetRequiredService<ISettingsDbService>();
|
||||
await settingsDb.UpdateSettingsFromDictionaryAsync(configUpdate.Settings);
|
||||
_logger.LogInformation("[{Channel}] [AnalyzerMqttClient] Persisted {Count} updated settings to FinlyticAnalyzer database.", "AnalyzerChannel", configUpdate.Settings.Count);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "[{Channel}] [AnalyzerMqttClient] Error processing MQTT config update event.", "AnalyzerChannel");
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (topic.StartsWith("finlytic/market/ticks/"))
|
||||
{
|
||||
ProcessTickMessage(topic, payloadStr);
|
||||
}
|
||||
else if (topic.StartsWith("finlytic/news/raw/", StringComparison.OrdinalIgnoreCase) ||
|
||||
topic.StartsWith("services/news/", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
await ProcessNewsMessageAsync(payloadStr, CancellationToken.None);
|
||||
}
|
||||
else if (topic.StartsWith("services/request/analyzer_TriggerManual/"))
|
||||
{
|
||||
var correlationId = topic.Split('/').Last();
|
||||
await HandleManualTriggerAsync(correlationId, payloadStr, CancellationToken.None);
|
||||
}
|
||||
else if (topic.StartsWith("finlytic/trades/closed/"))
|
||||
{
|
||||
await HandleClosedTradeFeedbackAsync(payloadStr);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "[{Channel}] Error processing incoming MQTT message on topic {Topic}", "AnalyzerChannel", topic);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task HandleClosedTradeFeedbackAsync(string payloadStr)
|
||||
{
|
||||
try
|
||||
{
|
||||
var options = new JsonSerializerOptions { PropertyNameCaseInsensitive = true };
|
||||
var closedDto = JsonSerializer.Deserialize<TradeProposalDto>(payloadStr, options);
|
||||
|
||||
if (closedDto != null && !string.IsNullOrWhiteSpace(closedDto.TradeId))
|
||||
{
|
||||
bool isWin = closedDto.Status.Contains("Profit", StringComparison.OrdinalIgnoreCase) ||
|
||||
closedDto.Status.Contains("Win", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
var feedback = new TradeFeedbackRecord
|
||||
{
|
||||
TradeId = closedDto.TradeId,
|
||||
AnalysisId = closedDto.AnalysisId,
|
||||
Sector = closedDto.Sector,
|
||||
Symbol = closedDto.Symbol,
|
||||
Isin = closedDto.Isin,
|
||||
EntryPrice = closedDto.EntryPrice,
|
||||
StopLoss = closedDto.StopLoss,
|
||||
TakeProfit = closedDto.TakeProfit,
|
||||
IsWin = isWin,
|
||||
VixRegime = closedDto.VixRegime,
|
||||
VixValue = closedDto.VixValue,
|
||||
CreatedAt = closedDto.CreatedAt,
|
||||
ClosedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
string feedbackDir = System.IO.Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "data", "feedback");
|
||||
if (!System.IO.Directory.Exists(feedbackDir))
|
||||
{
|
||||
System.IO.Directory.CreateDirectory(feedbackDir);
|
||||
}
|
||||
|
||||
string filePath = System.IO.Path.Combine(feedbackDir, $"{closedDto.TradeId}.json");
|
||||
await System.IO.File.WriteAllTextAsync(filePath, JsonSerializer.Serialize(new[] { feedback }, options));
|
||||
|
||||
_logger.LogInformation("[{Channel}] Processed closed trade feedback for {TradeId}. Saved to {FilePath}", "AnalyzerChannel", closedDto.TradeId, filePath);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "[{Channel}] Error processing closed trade feedback.", "AnalyzerChannel");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task HandleManualTriggerAsync(string correlationId, string payloadStr, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var manualReq = JsonSerializer.Deserialize(payloadStr, FinlyticJsonSerializerContext.Default.ManualAnalysisRpcRequest);
|
||||
if (manualReq == null || string.IsNullOrWhiteSpace(manualReq.Isin))
|
||||
{
|
||||
_logger.LogWarning("[{Channel}] Manual trigger received without valid request or ISIN.", "AnalyzerChannel");
|
||||
return;
|
||||
}
|
||||
|
||||
if (LogCategoryFilter.IsEnabled(LogCategory.AnalyzerManual))
|
||||
{
|
||||
_logger.LogInformation("[{Channel}] [ManualAnalyzer] [TRIGGERED] Processing rich manual trigger for ISIN '{Isin}' (Symbol: {Symbol}). CorrelationId: {CorrelationId}", "AnalyzerChannel", manualReq.Isin, manualReq.Symbol, correlationId);
|
||||
}
|
||||
|
||||
using var scope = _scopeFactory.CreateScope();
|
||||
var dbContext = scope.ServiceProvider.GetRequiredService<AnalyzerDbContext>();
|
||||
|
||||
var regime = _vixTracker.GetCurrentRegime();
|
||||
var currentVix = _vixTracker.GetCurrentVix();
|
||||
string analysisId = Guid.NewGuid().ToString("N");
|
||||
double winRate = _winRateCalculator.CalculateWinRate(manualReq.Sector, manualReq.Symbol, regime);
|
||||
|
||||
string riskLabel = manualReq.RiskScore > 70 ? $"Aggressiv ({manualReq.RiskScore}/100)" : (manualReq.RiskScore > 30 ? $"Balanced ({manualReq.RiskScore}/100)" : $"Konservativ ({manualReq.RiskScore}/100)");
|
||||
string timeframeFormatted = $"{manualReq.MinTimeframeValue}-{manualReq.MaxTimeframeValue} {manualReq.TimeframeUnit}";
|
||||
|
||||
var n8nRequest = new N8nAnalysisRequestDto
|
||||
{
|
||||
RequestId = analysisId,
|
||||
Timestamp = DateTime.UtcNow,
|
||||
TriggerType = "Manual",
|
||||
TargetAsset = new TargetAssetInfo
|
||||
{
|
||||
Symbol = manualReq.FundamentalsData?.Ticker ?? manualReq.Symbol.ToUpperInvariant(),
|
||||
Name = manualReq.FundamentalsData?.CompanyName ?? manualReq.Isin.ToUpperInvariant(),
|
||||
Isin = manualReq.Isin.ToUpperInvariant(),
|
||||
Sector = manualReq.Sector
|
||||
},
|
||||
MarketContext = new MarketContextInfo
|
||||
{
|
||||
Vix = currentVix,
|
||||
MarketRegime = regime.ToString()
|
||||
},
|
||||
FilterContext = new FilterContextInfo
|
||||
{
|
||||
ImpactScore = 1.0,
|
||||
RawNewsHeadline = string.IsNullOrWhiteSpace(manualReq.Headline) ? "Manual User Trigger" : manualReq.Headline
|
||||
},
|
||||
UserPreferences = new UserPreferencesInfo
|
||||
{
|
||||
RiskScore = manualReq.RiskScore,
|
||||
RiskTolerance = riskLabel,
|
||||
MinTimeframeValue = manualReq.MinTimeframeValue,
|
||||
MaxTimeframeValue = manualReq.MaxTimeframeValue,
|
||||
TimeframeUnit = manualReq.TimeframeUnit,
|
||||
TimeframeFormatted = timeframeFormatted,
|
||||
InstrumentType = manualReq.InstrumentType,
|
||||
UserNotes = manualReq.UserNotes
|
||||
},
|
||||
TradeFeedback = new TradeFeedbackInfo
|
||||
{
|
||||
TotalAssetTrades = 0,
|
||||
AssetWinRate = winRate,
|
||||
AvgReturnPercent = 0.0,
|
||||
LastTradeResult = "UNKNOWN"
|
||||
},
|
||||
TechnicalContext = new TechnicalContextInfo
|
||||
{
|
||||
Rsi = manualReq.TaData?.Indicators?.LastOrDefault()?.Rsi14?.ToString("F1") ?? "N/A",
|
||||
SupertrendStatus = manualReq.TaData?.Indicators?.LastOrDefault()?.SupertrendDirection ?? "NEUTRAL",
|
||||
Atr = manualReq.TaData?.Indicators?.LastOrDefault()?.Atr14?.ToString("F2") ?? "N/A",
|
||||
Sma50 = (double?)manualReq.TaData?.Indicators?.LastOrDefault()?.Sma50,
|
||||
Sma200 = (double?)manualReq.TaData?.Indicators?.LastOrDefault()?.Sma200,
|
||||
DetectedPatterns = manualReq.TaData?.Patterns?.Select(p => new PatternContextInfo
|
||||
{
|
||||
PatternName = p.Type,
|
||||
BreakoutDirection = p.BreakoutSignal?.Direction,
|
||||
TargetPrice = (double?)p.BreakoutSignal?.TargetPrice,
|
||||
PotentialPercent = (double?)p.BreakoutSignal?.PotentialPercent
|
||||
}).ToList() ?? new List<PatternContextInfo>()
|
||||
},
|
||||
SentimentContext = new SentimentContextInfo
|
||||
{
|
||||
AssetSentimentScore = manualReq.SentimentData?.CurrentSummary?.CompoundScore ?? 0.0,
|
||||
SectorSentimentScore = 0.0,
|
||||
NewsSentimentSummary = manualReq.SentimentData?.CurrentSummary?.SentimentLabel ?? "Neutral"
|
||||
},
|
||||
FundamentalContext = new FundamentalContextInfo
|
||||
{
|
||||
PeRatio = (double?)manualReq.FundamentalsData?.PeRatioTrailing,
|
||||
ForwardPeRatio = (double?)manualReq.FundamentalsData?.PeRatioForward,
|
||||
PegRatio = (double?)manualReq.FundamentalsData?.PegRatio,
|
||||
MarketCap = (double?)manualReq.FundamentalsData?.MarketCapitalization,
|
||||
DebtToEquity = (double?)manualReq.FundamentalsData?.DebtToEquity,
|
||||
GrossMargin = (double?)manualReq.FundamentalsData?.GrossMargin,
|
||||
NetProfitMargin = (double?)manualReq.FundamentalsData?.NetProfitMargin,
|
||||
ReturnOnEquity = (double?)manualReq.FundamentalsData?.ReturnOnEquity,
|
||||
DividendYield = (double?)manualReq.FundamentalsData?.DividendYield,
|
||||
ShortPercentOfFloat = (double?)manualReq.FundamentalsData?.ShortPercentOfFloat,
|
||||
AnalystTargetMedian = (double?)manualReq.FundamentalsData?.PriceTargetMedian,
|
||||
EvToEbitda = (double?)manualReq.FundamentalsData?.EvToEbitda
|
||||
}
|
||||
};
|
||||
|
||||
var n8nResponse = await _n8nService.EvaluateAssetAsync(n8nRequest, cancellationToken);
|
||||
|
||||
var settingsService = scope.ServiceProvider.GetRequiredService<ISettingsDbService>();
|
||||
var settings = await settingsService.GetSettingsAsync();
|
||||
double minSignalScore = settings.MinSignalScore;
|
||||
|
||||
double confidenceScore = n8nResponse?.EvalScore > 0 ? n8nResponse.EvalScore : 0.75;
|
||||
bool shouldProceed = n8nResponse != null &&
|
||||
string.Equals(n8nResponse.AiDecision, "Proceed", StringComparison.OrdinalIgnoreCase) &&
|
||||
(confidenceScore * 100.0) >= minSignalScore &&
|
||||
winRate >= minSignalScore;
|
||||
|
||||
TradeProposalDto? proposalDto = null;
|
||||
if (n8nResponse != null)
|
||||
{
|
||||
proposalDto = new TradeProposalDto
|
||||
{
|
||||
TradeId = "PROP-" + Guid.NewGuid().ToString("N")[..10].ToUpperInvariant(),
|
||||
AnalysisId = analysisId,
|
||||
EventId = analysisId,
|
||||
Sector = manualReq.Sector,
|
||||
Symbol = manualReq.Symbol.ToUpperInvariant(),
|
||||
Isin = manualReq.Isin.ToUpperInvariant(),
|
||||
CompanyName = manualReq.FundamentalsData?.CompanyName ?? manualReq.Symbol,
|
||||
EntryPrice = manualReq.CurrentPrice,
|
||||
SignalType = string.Equals(n8nResponse.SuggestedDirection, "Short", StringComparison.OrdinalIgnoreCase) ? "SELL" : "BUY",
|
||||
Status = shouldProceed ? "Proposed" : "Rejected",
|
||||
RiskTolerance = n8nResponse.SuggestedRisk,
|
||||
Timeframe = timeframeFormatted,
|
||||
InstrumentType = manualReq.InstrumentType,
|
||||
WinRate = winRate,
|
||||
VixRegime = regime,
|
||||
VixValue = currentVix,
|
||||
TtlMinutes = 60,
|
||||
Reasoning = $"Manual n8n Evaluation ({n8nResponse.AiDecision}): {n8nResponse.AiReasoning}",
|
||||
|
||||
StopLoss = n8nResponse.ExecutionPlan?.StopLoss ?? 0,
|
||||
TakeProfit = n8nResponse.ExecutionPlan?.TakeProfitTargets != null && n8nResponse.ExecutionPlan.TakeProfitTargets.Count > 0 ? n8nResponse.ExecutionPlan.TakeProfitTargets[0] : 0,
|
||||
EntryZoneMin = n8nResponse.ExecutionPlan?.EntryZone?.Min,
|
||||
EntryZoneMax = n8nResponse.ExecutionPlan?.EntryZone?.Max,
|
||||
TakeProfitTargets = n8nResponse.ExecutionPlan?.TakeProfitTargets,
|
||||
RiskRewardRatio = n8nResponse.ExecutionPlan?.RiskRewardRatio,
|
||||
MaxLeverage = n8nResponse.ExecutionPlan?.MaxLeverage,
|
||||
TechnicalRationale = n8nResponse.DetailedAnalysis?.TechnicalRationale ?? string.Empty,
|
||||
FundamentalRationale = n8nResponse.DetailedAnalysis?.FundamentalRationale ?? string.Empty,
|
||||
RiskWarning = n8nResponse.DetailedAnalysis?.RiskWarning ?? string.Empty,
|
||||
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
}
|
||||
|
||||
var analysisEntity = new AnalysisEntity
|
||||
{
|
||||
AnalysisId = analysisId,
|
||||
EventId = analysisId,
|
||||
Sector = manualReq.Sector,
|
||||
Symbol = manualReq.Symbol.ToUpperInvariant(),
|
||||
Isin = manualReq.Isin.ToUpperInvariant(),
|
||||
VixRegime = regime,
|
||||
VixValue = currentVix,
|
||||
ImpactScore = 1.0,
|
||||
WinRate = winRate,
|
||||
RawDataJson = JsonSerializer.Serialize(manualReq),
|
||||
AiOutputJson = proposalDto != null ? JsonSerializer.Serialize(proposalDto) : "{}",
|
||||
N8nResponseJson = n8nResponse != null ? JsonSerializer.Serialize(n8nResponse) : "{}",
|
||||
N8nEvalScore = n8nResponse?.EvalScore ?? 0,
|
||||
N8nDecision = n8nResponse?.AiDecision ?? "Rejected",
|
||||
IsTradeProposed = shouldProceed,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
dbContext.Analyses.Add(analysisEntity);
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
|
||||
var responseTopic = $"services/response/analyzer_TriggerManual/{correlationId}";
|
||||
var responsePayload = new ManualAnalysisResponseDto
|
||||
{
|
||||
AnalysisId = analysisId,
|
||||
IsTradeProposed = shouldProceed,
|
||||
Status = shouldProceed ? "Success" : "Rejected",
|
||||
Recommendation = shouldProceed ? "RECOMMENDED" : "NOT_RECOMMENDED",
|
||||
N8nResponse = n8nResponse,
|
||||
Proposal = proposalDto
|
||||
};
|
||||
|
||||
await PublishAsync(responseTopic, responsePayload);
|
||||
|
||||
if (proposalDto != null && shouldProceed)
|
||||
{
|
||||
string propTopic = $"finlytic/trades/proposed/{(string.IsNullOrWhiteSpace(manualReq.Sector) ? "general" : manualReq.Sector.ToLowerInvariant())}/{manualReq.Symbol.ToLowerInvariant()}";
|
||||
await PublishAsync(propTopic, proposalDto);
|
||||
_logger.LogInformation("[{Channel}] [ManualAnalyzer] [DISPATCHED] Dispatched Manual Trade Proposal {AnalysisId} to topic {Topic}", "AnalyzerChannel", analysisId, propTopic);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "[{Channel}] Failed to handle manual trigger.", "AnalyzerChannel");
|
||||
}
|
||||
}
|
||||
|
||||
private void ProcessTickMessage(string topic, string payloadStr)
|
||||
{
|
||||
if (topic.EndsWith("VIX", StringComparison.OrdinalIgnoreCase) || topic.EndsWith("^VIX", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
try
|
||||
{
|
||||
var tick = JsonSerializer.Deserialize(payloadStr, FinlyticJsonSerializerContext.Default.TickMessageDto);
|
||||
if (tick != null && tick.Price > 0)
|
||||
{
|
||||
_vixTracker.UpdateVixFromTick(tick.Price);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "[{Channel}] Failed to parse VIX tick message.", "AnalyzerChannel");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ProcessNewsMessageAsync(string payloadStr, CancellationToken cancellationToken)
|
||||
{
|
||||
var newsArticle = JsonSerializer.Deserialize(payloadStr, FinlyticJsonSerializerContext.Default.NewsArticleDto);
|
||||
if (newsArticle == null) return;
|
||||
|
||||
var regime = _vixTracker.GetCurrentRegime();
|
||||
var currentVix = _vixTracker.GetCurrentVix();
|
||||
|
||||
var filterResult = _filterEngine.EvaluateNews(newsArticle, regime);
|
||||
if (!filterResult.Passed)
|
||||
{
|
||||
if (LogCategoryFilter.IsEnabled(LogCategory.AnalyzerAuto))
|
||||
{
|
||||
_logger.LogInformation("[{Channel}] [AutoScreener] [SKIPPED] News message skipped for ISIN '{Isin}'. Reason: {Reason}", "AnalyzerChannel", filterResult.Isin, filterResult.RejectReason);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (LogCategoryFilter.IsEnabled(LogCategory.AnalyzerAuto))
|
||||
{
|
||||
_logger.LogInformation("[{Channel}] [AutoScreener] [TRIGGERED] Screening market opportunity for ISIN '{Isin}'...", "AnalyzerChannel", filterResult.Isin);
|
||||
}
|
||||
|
||||
string analysisId = Guid.NewGuid().ToString("N");
|
||||
string eventId = newsArticle.Id != Guid.Empty ? newsArticle.Id.ToString() : analysisId;
|
||||
string rawHeadline = newsArticle.Title ?? string.Empty;
|
||||
|
||||
double winRate = _winRateCalculator.CalculateWinRate(filterResult.Sector, filterResult.Symbol, regime);
|
||||
|
||||
int riskScore = 50;
|
||||
string riskTolerance = "Balanced (50/100)";
|
||||
int minTf = 4;
|
||||
int maxTf = 7;
|
||||
|
||||
if (winRate < 45.0)
|
||||
{
|
||||
riskScore = 30;
|
||||
riskTolerance = "Konservativ (30/100)";
|
||||
minTf = 7;
|
||||
maxTf = 14;
|
||||
}
|
||||
else if (winRate >= 65.0)
|
||||
{
|
||||
riskScore = 75;
|
||||
riskTolerance = "Aggressiv (75/100)";
|
||||
minTf = 1;
|
||||
maxTf = 4;
|
||||
}
|
||||
|
||||
TechnicalContextInfo taInfo = new();
|
||||
FundamentalContextInfo fundInfo = new();
|
||||
SentimentContextInfo sentInfo = new();
|
||||
|
||||
string resolvedSymbol = filterResult.Symbol;
|
||||
string resolvedName = filterResult.Symbol;
|
||||
|
||||
if (newsArticle.MatchedAssets != null && newsArticle.MatchedAssets.Count > 0)
|
||||
{
|
||||
var firstAsset = newsArticle.MatchedAssets[0];
|
||||
if (!string.IsNullOrWhiteSpace(firstAsset.Name))
|
||||
{
|
||||
resolvedName = firstAsset.Name;
|
||||
if (resolvedSymbol == "UNKNOWN" || resolvedSymbol == filterResult.Isin)
|
||||
{
|
||||
resolvedSymbol = resolvedName;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
FinlyticCore.Dtos.TechnicalAnalysis.TechnicalAnalysisDto? taResp = null;
|
||||
FinlyticCore.Dtos.Fundamentals.AssetFundamentalsDto? fundResp = null;
|
||||
FinlyticCore.Dtos.TechnicalAnalysis.LivePriceDto? livePriceResp = null;
|
||||
|
||||
try
|
||||
{
|
||||
if (IsConnected)
|
||||
{
|
||||
var isinReq = new IsinRequest(filterResult.Isin);
|
||||
|
||||
livePriceResp = await SendRpcRequestAsync<FinlyticCore.Dtos.TechnicalAnalysis.LivePriceDto, IsinRequest>(
|
||||
"tr_GetLivePrice", isinReq, TimeSpan.FromSeconds(3));
|
||||
|
||||
taResp = await SendRpcRequestAsync<FinlyticCore.Dtos.TechnicalAnalysis.TechnicalAnalysisDto, IsinRequest>(
|
||||
"ta_GetAnalysis", isinReq, TimeSpan.FromSeconds(3));
|
||||
if (taResp?.Indicators != null)
|
||||
{
|
||||
var latestIndicator = taResp.Indicators.LastOrDefault();
|
||||
taInfo = new TechnicalContextInfo
|
||||
{
|
||||
Rsi = latestIndicator?.Rsi14?.ToString("F1") ?? "50.0",
|
||||
SupertrendStatus = latestIndicator?.SupertrendDirection ?? "NEUTRAL",
|
||||
Atr = latestIndicator?.Atr14?.ToString("F2") ?? "0.0",
|
||||
Sma50 = (double?)latestIndicator?.Sma50,
|
||||
Sma200 = (double?)latestIndicator?.Sma200,
|
||||
DetectedPatterns = taResp.Patterns?.Select(p => new PatternContextInfo
|
||||
{
|
||||
PatternName = p.Type,
|
||||
BreakoutDirection = p.BreakoutSignal?.Direction,
|
||||
TargetPrice = (double?)p.BreakoutSignal?.TargetPrice,
|
||||
PotentialPercent = (double?)p.BreakoutSignal?.PotentialPercent
|
||||
}).ToList() ?? new List<PatternContextInfo>()
|
||||
};
|
||||
}
|
||||
|
||||
fundResp = await SendRpcRequestAsync<FinlyticCore.Dtos.Fundamentals.AssetFundamentalsDto, IsinRequest>(
|
||||
"fundamentals_Get", isinReq, TimeSpan.FromSeconds(3));
|
||||
if (fundResp != null)
|
||||
{
|
||||
resolvedSymbol = !string.IsNullOrWhiteSpace(fundResp.Ticker) ? fundResp.Ticker : resolvedSymbol;
|
||||
resolvedName = !string.IsNullOrWhiteSpace(fundResp.CompanyName) ? fundResp.CompanyName : resolvedName;
|
||||
|
||||
fundInfo = new FundamentalContextInfo
|
||||
{
|
||||
PeRatio = (double?)fundResp.PeRatioTrailing,
|
||||
ForwardPeRatio = (double?)fundResp.PeRatioForward,
|
||||
PegRatio = (double?)fundResp.PegRatio,
|
||||
MarketCap = (double?)fundResp.MarketCapitalization,
|
||||
DebtToEquity = (double?)fundResp.DebtToEquity,
|
||||
GrossMargin = (double?)fundResp.GrossMargin,
|
||||
NetProfitMargin = (double?)fundResp.NetProfitMargin,
|
||||
ReturnOnEquity = (double?)fundResp.ReturnOnEquity,
|
||||
DividendYield = (double?)fundResp.DividendYield,
|
||||
ShortPercentOfFloat = (double?)fundResp.ShortPercentOfFloat,
|
||||
AnalystTargetMedian = (double?)fundResp.PriceTargetMedian,
|
||||
EvToEbitda = (double?)fundResp.EvToEbitda
|
||||
};
|
||||
}
|
||||
|
||||
var sentResp = await SendRpcRequestAsync<FinlyticCore.Dtos.Sentiment.IsinSentimentSummaryDto, IsinRequest>(
|
||||
"sentiment_GetIsin", isinReq, TimeSpan.FromSeconds(3));
|
||||
if (sentResp != null)
|
||||
{
|
||||
sentInfo = new SentimentContextInfo
|
||||
{
|
||||
AssetSentimentScore = sentResp.CurrentSummary?.CompoundScore ?? 0.0,
|
||||
SectorSentimentScore = 0.5,
|
||||
NewsSentimentSummary = sentResp.CurrentSummary?.SentimentLabel ?? "Neutral"
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "[{Channel}] Failed to fetch context data for auto screener analysis.", "AnalyzerChannel");
|
||||
}
|
||||
|
||||
var n8nRequest = new N8nAnalysisRequestDto
|
||||
{
|
||||
RequestId = analysisId,
|
||||
Timestamp = DateTime.UtcNow,
|
||||
TriggerType = "AutoScreener",
|
||||
TargetAsset = new TargetAssetInfo
|
||||
{
|
||||
Symbol = resolvedSymbol.ToUpperInvariant(),
|
||||
Name = resolvedName,
|
||||
Isin = filterResult.Isin.ToUpperInvariant(),
|
||||
Sector = filterResult.Sector
|
||||
},
|
||||
MarketContext = new MarketContextInfo
|
||||
{
|
||||
Vix = currentVix,
|
||||
MarketRegime = regime.ToString()
|
||||
},
|
||||
FilterContext = new FilterContextInfo
|
||||
{
|
||||
ImpactScore = filterResult.ImpactScore,
|
||||
RawNewsHeadline = rawHeadline
|
||||
},
|
||||
UserPreferences = new UserPreferencesInfo
|
||||
{
|
||||
RiskScore = riskScore,
|
||||
RiskTolerance = riskTolerance,
|
||||
MinTimeframeValue = minTf,
|
||||
MaxTimeframeValue = maxTf,
|
||||
TimeframeUnit = "Tage",
|
||||
TimeframeFormatted = $"{minTf}-{maxTf} Tage",
|
||||
InstrumentType = "KnockOut",
|
||||
UserNotes = "High-Conviction Screener Mode: Evaluate underlying data for strong reliable chart moves."
|
||||
},
|
||||
TradeFeedback = new TradeFeedbackInfo
|
||||
{
|
||||
TotalAssetTrades = 0,
|
||||
AssetWinRate = winRate,
|
||||
AvgReturnPercent = 0.0,
|
||||
LastTradeResult = "UNKNOWN"
|
||||
},
|
||||
TechnicalContext = taInfo,
|
||||
SentimentContext = sentInfo,
|
||||
FundamentalContext = fundInfo
|
||||
};
|
||||
|
||||
var n8nResponse = await _n8nService.EvaluateAssetAsync(n8nRequest, cancellationToken);
|
||||
|
||||
double minSignalScore = 75.0;
|
||||
using (var scope = _scopeFactory.CreateScope())
|
||||
{
|
||||
var settingsService = scope.ServiceProvider.GetRequiredService<ISettingsDbService>();
|
||||
var settings = await settingsService.GetSettingsAsync();
|
||||
minSignalScore = settings.MinSignalScore;
|
||||
}
|
||||
|
||||
double confidenceScore = n8nResponse?.EvalScore > 0 ? n8nResponse.EvalScore : 0.75;
|
||||
bool isHighConviction = n8nResponse != null &&
|
||||
string.Equals(n8nResponse.AiDecision, "Proceed", StringComparison.OrdinalIgnoreCase) &&
|
||||
(confidenceScore * 100.0) >= minSignalScore &&
|
||||
winRate >= minSignalScore;
|
||||
|
||||
string finalSymbol = !string.IsNullOrWhiteSpace(resolvedSymbol) && resolvedSymbol != "UNKNOWN"
|
||||
? resolvedSymbol
|
||||
: (!string.IsNullOrWhiteSpace(filterResult.Symbol) && filterResult.Symbol != "UNKNOWN" ? filterResult.Symbol : filterResult.Isin);
|
||||
|
||||
string finalName = !string.IsNullOrWhiteSpace(resolvedName) && resolvedName != "UNKNOWN"
|
||||
? resolvedName
|
||||
: finalSymbol;
|
||||
|
||||
string marketRegion = filterResult.Isin.StartsWith("DE", StringComparison.OrdinalIgnoreCase) ? "GERMAN_EQUITIES" : "US_EQUITIES";
|
||||
|
||||
var supportLevels = new List<double>();
|
||||
var resistanceLevels = new List<double>();
|
||||
|
||||
double currentPrice = (double)(livePriceResp?.CurrentPrice > 0 ? livePriceResp.CurrentPrice : (fundResp?.CurrentPrice > 0 ? fundResp.CurrentPrice : 0.0m));
|
||||
if (currentPrice > 0)
|
||||
{
|
||||
supportLevels.Add(Math.Round(currentPrice * 0.98, 2));
|
||||
supportLevels.Add(Math.Round(currentPrice * 0.95, 2));
|
||||
resistanceLevels.Add(Math.Round(currentPrice * 1.03, 2));
|
||||
resistanceLevels.Add(Math.Round(currentPrice * 1.06, 2));
|
||||
}
|
||||
|
||||
if (n8nResponse?.ExecutionPlan?.EntryZone != null)
|
||||
{
|
||||
if (n8nResponse.ExecutionPlan.EntryZone.Min > 0) supportLevels.Insert(0, (double)n8nResponse.ExecutionPlan.EntryZone.Min);
|
||||
if (n8nResponse.ExecutionPlan.EntryZone.Max > 0) resistanceLevels.Insert(0, (double)n8nResponse.ExecutionPlan.EntryZone.Max);
|
||||
}
|
||||
|
||||
var recommendation = new AssetRecommendationDto
|
||||
{
|
||||
Mode = "AUTO_SCREENER",
|
||||
Timestamp = DateTime.UtcNow,
|
||||
RecommendedAsset = new RecommendedAssetInfo
|
||||
{
|
||||
Symbol = finalSymbol,
|
||||
CompanyName = finalName,
|
||||
Isin = filterResult.Isin,
|
||||
Market = marketRegion,
|
||||
Bias = string.Equals(n8nResponse?.SuggestedDirection, "Short", StringComparison.OrdinalIgnoreCase) ? "BEARISH" : "BULLISH",
|
||||
ConfidenceScore = Math.Round(confidenceScore, 2),
|
||||
Timeframe = !string.IsNullOrWhiteSpace(n8nResponse?.SuggestedTimeframe) ? n8nResponse.SuggestedTimeframe : "1D"
|
||||
},
|
||||
Rationale = new RecommendationRationaleInfo
|
||||
{
|
||||
PatternDetected = taInfo.DetectedPatterns?.Count > 0
|
||||
? string.Join(", ", taInfo.DetectedPatterns.Select(p => p.PatternName))
|
||||
: (!string.IsNullOrWhiteSpace(n8nResponse?.DetailedAnalysis?.TechnicalRationale) ? n8nResponse.DetailedAnalysis.TechnicalRationale : "Multi-Timeframe Trend & Volume Confluence"),
|
||||
VixContext = $"VIX at {currentVix:F1} ({regime} volatility environment)",
|
||||
KeyTechnicalLevels = new KeyTechnicalLevelsInfo
|
||||
{
|
||||
Support = supportLevels.Distinct().ToList(),
|
||||
Resistance = resistanceLevels.Distinct().ToList()
|
||||
},
|
||||
Summary = !string.IsNullOrWhiteSpace(n8nReasoning(n8nResponse))
|
||||
? n8nResponse!.AiReasoning
|
||||
: "High conviction setup based on multi-timeframe technical confluence, sentiment, and fundamental data."
|
||||
},
|
||||
ActionRequired = isHighConviction ? "PROMPT_USER_FOR_MANUAL_TRADE" : "NO_ACTION"
|
||||
};
|
||||
|
||||
using (var scope = _scopeFactory.CreateScope())
|
||||
{
|
||||
var dbContext = scope.ServiceProvider.GetRequiredService<AnalyzerDbContext>();
|
||||
|
||||
var analysisEntity = new AnalysisEntity
|
||||
{
|
||||
AnalysisId = analysisId,
|
||||
EventId = eventId,
|
||||
Sector = filterResult.Sector,
|
||||
Symbol = finalSymbol,
|
||||
Isin = filterResult.Isin,
|
||||
VixRegime = regime,
|
||||
VixValue = currentVix,
|
||||
ImpactScore = filterResult.ImpactScore,
|
||||
WinRate = winRate,
|
||||
RawDataJson = payloadStr,
|
||||
AiOutputJson = JsonSerializer.Serialize(recommendation),
|
||||
N8nResponseJson = n8nResponse != null ? JsonSerializer.Serialize(n8nResponse) : "{}",
|
||||
N8nEvalScore = n8nResponse?.EvalScore ?? 0,
|
||||
N8nDecision = n8nResponse?.AiDecision ?? "None",
|
||||
IsTradeProposed = isHighConviction,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
dbContext.Analyses.Add(analysisEntity);
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
if (isHighConviction && n8nResponse != null)
|
||||
{
|
||||
var autoProposalDto = new TradeProposalDto
|
||||
{
|
||||
TradeId = "PROP-" + Guid.NewGuid().ToString("N")[..10].ToUpperInvariant(),
|
||||
AnalysisId = analysisId,
|
||||
EventId = eventId,
|
||||
Sector = filterResult.Sector,
|
||||
Symbol = finalSymbol,
|
||||
Isin = filterResult.Isin,
|
||||
CompanyName = finalName,
|
||||
EntryPrice = (decimal)currentPrice,
|
||||
SignalType = string.Equals(n8nResponse.SuggestedDirection, "Short", StringComparison.OrdinalIgnoreCase) ? "SELL" : "BUY",
|
||||
Status = "Proposed",
|
||||
RiskTolerance = n8nResponse.SuggestedRisk ?? "Balanced",
|
||||
Timeframe = $"{minTf}-{maxTf} Tage",
|
||||
InstrumentType = "KnockOut",
|
||||
WinRate = winRate,
|
||||
VixRegime = regime,
|
||||
VixValue = currentVix,
|
||||
TtlMinutes = 180,
|
||||
Reasoning = n8nResponse.AiReasoning ?? "Auto-Screener High Conviction Trade",
|
||||
StopLoss = n8nResponse.ExecutionPlan?.StopLoss ?? 0,
|
||||
TakeProfit = n8nResponse.ExecutionPlan?.TakeProfitTargets != null && n8nResponse.ExecutionPlan.TakeProfitTargets.Count > 0 ? n8nResponse.ExecutionPlan.TakeProfitTargets[0] : 0,
|
||||
EntryZoneMin = n8nResponse.ExecutionPlan?.EntryZone?.Min,
|
||||
EntryZoneMax = n8nResponse.ExecutionPlan?.EntryZone?.Max,
|
||||
TakeProfitTargets = n8nResponse.ExecutionPlan?.TakeProfitTargets,
|
||||
RiskRewardRatio = n8nResponse.ExecutionPlan?.RiskRewardRatio,
|
||||
MaxLeverage = n8nResponse.ExecutionPlan?.MaxLeverage,
|
||||
TechnicalRationale = n8nResponse.DetailedAnalysis?.TechnicalRationale ?? string.Empty,
|
||||
FundamentalRationale = n8nResponse.DetailedAnalysis?.FundamentalRationale ?? string.Empty,
|
||||
RiskWarning = n8nResponse.DetailedAnalysis?.RiskWarning ?? string.Empty,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
string propTopic = $"finlytic/trades/proposed/{(string.IsNullOrWhiteSpace(filterResult.Sector) ? "general" : filterResult.Sector.ToLowerInvariant())}/{finalSymbol.ToLowerInvariant()}";
|
||||
await PublishAsync(propTopic, autoProposalDto);
|
||||
_logger.LogInformation("[{Channel}] [AutoScreener] Dispatched High-Conviction Proposal {TradeId} to topic {Topic}", "AnalyzerChannel", autoProposalDto.TradeId, propTopic);
|
||||
}
|
||||
|
||||
if (isHighConviction)
|
||||
{
|
||||
string recTopic = $"finlytic/recommendations/auto/{(string.IsNullOrWhiteSpace(filterResult.Sector) ? "general" : filterResult.Sector.ToLowerInvariant())}/{finalSymbol.ToLowerInvariant()}";
|
||||
await PublishAsync(recTopic, recommendation);
|
||||
await PublishAsync("finlytic/recommendations/auto", recommendation);
|
||||
|
||||
if (LogCategoryFilter.IsEnabled(LogCategory.AnalyzerAuto))
|
||||
{
|
||||
_logger.LogInformation("[{Channel}] [AutoScreener] [RECOMMENDED] High-Conviction Opportunity found for {Symbol} (Bias: {Bias}, Confidence: {Score:F2}). Published to {Topic}",
|
||||
"AnalyzerChannel", finalSymbol, recommendation.RecommendedAsset.Bias, recommendation.RecommendedAsset.ConfidenceScore, recTopic);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
if (LogCategoryFilter.IsEnabled(LogCategory.AnalyzerAuto))
|
||||
{
|
||||
_logger.LogInformation("[{Channel}] [AutoScreener] [DROPPED] Low-conviction signal for {Symbol} dropped (Confidence: {Score:F2}, Action: NO_ACTION)",
|
||||
"AnalyzerChannel", finalSymbol, recommendation.RecommendedAsset.ConfidenceScore);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static string n8nReasoning(N8nAnalysisResponseDto? resp) => resp?.AiReasoning ?? string.Empty;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.Hosting.Lifetime": "Information"
|
||||
}
|
||||
},
|
||||
"ConnectionStrings": {
|
||||
"DefaultConnection": "Host=localhost;Database=finlytic_analyzer;Username=admin;Password=admin"
|
||||
},
|
||||
"MQTT": {
|
||||
"Host": "localhost",
|
||||
"Port": "1883",
|
||||
"ClientId": "finlytic_analyzer"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user