feat(technicals): add technical analysis microservice with indicator engines, pattern detectors, and strategies
This commit is contained in:
@@ -0,0 +1,129 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Text.Json;
|
||||||
|
using FinlyticCore.Database;
|
||||||
|
using FinlyticCore.Dtos.TechnicalAnalysis;
|
||||||
|
using FinlyticCore.Entities.Settings;
|
||||||
|
using FinlyticTechnicals.Entities;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Design;
|
||||||
|
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||||
|
|
||||||
|
namespace FinlyticTechnicals.Database;
|
||||||
|
|
||||||
|
public class TechnicalAnalysisDbContext : DbContext, ISettingsDbContext
|
||||||
|
{
|
||||||
|
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||||
|
{
|
||||||
|
PropertyNameCaseInsensitive = true,
|
||||||
|
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||||
|
WriteIndented = false
|
||||||
|
};
|
||||||
|
|
||||||
|
public TechnicalAnalysisDbContext(DbContextOptions<TechnicalAnalysisDbContext> options) : base(options)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public DbSet<SettingEntity> DynamicSettings => Set<SettingEntity>();
|
||||||
|
public DbSet<FtaCandleEntity> FtaCandles => Set<FtaCandleEntity>();
|
||||||
|
public DbSet<FtaTechnicalSetupEntity> FtaTechnicalSetups => Set<FtaTechnicalSetupEntity>();
|
||||||
|
public DbSet<FtaDetectedPatternEntity> FtaDetectedPatterns => Set<FtaDetectedPatternEntity>();
|
||||||
|
public DbSet<FtaMonitoredUniverseAssetEntity> MonitoredUniverseAssets => Set<FtaMonitoredUniverseAssetEntity>();
|
||||||
|
|
||||||
|
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
base.OnModelCreating(modelBuilder);
|
||||||
|
|
||||||
|
// 1. Settings Table
|
||||||
|
modelBuilder.Entity<SettingEntity>(entity =>
|
||||||
|
{
|
||||||
|
entity.HasKey(e => e.Id);
|
||||||
|
entity.HasIndex(e => e.Key).IsUnique();
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2. FTA Candles Table & Composite Time-Series Index
|
||||||
|
modelBuilder.Entity<FtaCandleEntity>(entity =>
|
||||||
|
{
|
||||||
|
entity.HasKey(e => e.Id);
|
||||||
|
entity.HasIndex(e => new { e.Isin, e.Timeframe, e.TimestampUtc });
|
||||||
|
entity.HasIndex(e => e.TimestampUtc);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 3. JSONB Value Converters for Complex Types
|
||||||
|
var exitPlanConverter = new ValueConverter<ExitPlan, string>(
|
||||||
|
v => JsonSerializer.Serialize(v, JsonOptions),
|
||||||
|
v => JsonSerializer.Deserialize<ExitPlan>(v, JsonOptions) ?? new ExitPlan(ExitStrategyType.FixedSingleTarget, 0m, new List<TakeProfitStage>(), null, null, null, null)
|
||||||
|
);
|
||||||
|
|
||||||
|
var patternsConverter = new ValueConverter<List<PatternResultDto>, string>(
|
||||||
|
v => JsonSerializer.Serialize(v, JsonOptions),
|
||||||
|
v => JsonSerializer.Deserialize<List<PatternResultDto>>(v, JsonOptions) ?? new List<PatternResultDto>()
|
||||||
|
);
|
||||||
|
|
||||||
|
var indicatorSnapshotConverter = new ValueConverter<Dictionary<string, decimal>, string>(
|
||||||
|
v => JsonSerializer.Serialize(v, JsonOptions),
|
||||||
|
v => JsonSerializer.Deserialize<Dictionary<string, decimal>>(v, JsonOptions) ?? new Dictionary<string, decimal>()
|
||||||
|
);
|
||||||
|
|
||||||
|
var extraDataConverter = new ValueConverter<Dictionary<string, object>?, string>(
|
||||||
|
v => v == null ? "{}" : JsonSerializer.Serialize(v, JsonOptions),
|
||||||
|
v => string.IsNullOrWhiteSpace(v) ? null : JsonSerializer.Deserialize<Dictionary<string, object>>(v, JsonOptions)
|
||||||
|
);
|
||||||
|
|
||||||
|
// 4. FTA Technical Setups Table & JSONB mappings
|
||||||
|
modelBuilder.Entity<FtaTechnicalSetupEntity>(entity =>
|
||||||
|
{
|
||||||
|
entity.HasKey(e => e.SetupId);
|
||||||
|
entity.HasIndex(e => new { e.Isin, e.IsActive, e.ExpiresAtUtc });
|
||||||
|
entity.HasIndex(e => e.CreatedAtUtc);
|
||||||
|
entity.HasIndex(e => e.QualityScore);
|
||||||
|
entity.HasIndex(e => e.IsTopPick);
|
||||||
|
entity.HasIndex(e => new { e.IsActive, e.IsTopPick, e.QualityScore });
|
||||||
|
|
||||||
|
|
||||||
|
entity.Property(e => e.ExitPlan)
|
||||||
|
.HasColumnType("jsonb")
|
||||||
|
.HasConversion(exitPlanConverter);
|
||||||
|
|
||||||
|
entity.Property(e => e.TriggeringPatterns)
|
||||||
|
.HasColumnType("jsonb")
|
||||||
|
.HasConversion(patternsConverter);
|
||||||
|
|
||||||
|
entity.Property(e => e.IndicatorSnapshot)
|
||||||
|
.HasColumnType("jsonb")
|
||||||
|
.HasConversion(indicatorSnapshotConverter);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 5. FTA Detected Patterns Table & JSONB mappings
|
||||||
|
modelBuilder.Entity<FtaDetectedPatternEntity>(entity =>
|
||||||
|
{
|
||||||
|
entity.HasKey(e => e.Id);
|
||||||
|
entity.HasIndex(e => new { e.Isin, e.DetectedAtUtc });
|
||||||
|
entity.HasIndex(e => e.PatternType);
|
||||||
|
entity.HasIndex(e => e.QualityScore);
|
||||||
|
|
||||||
|
entity.Property(e => e.ExtraData)
|
||||||
|
.HasColumnType("jsonb")
|
||||||
|
.HasConversion(extraDataConverter);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 6. Monitored Universe Assets Table (persisted backing store for TechnicalUniverseManager, cleared on
|
||||||
|
// every service startup - see FtaMonitoredUniverseAssetEntity's doc comment)
|
||||||
|
modelBuilder.Entity<FtaMonitoredUniverseAssetEntity>(entity =>
|
||||||
|
{
|
||||||
|
entity.HasKey(e => e.Isin);
|
||||||
|
entity.HasIndex(e => e.Source);
|
||||||
|
entity.HasIndex(e => e.ExpiresAtUtc);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class TechnicalAnalysisDbContextFactory : IDesignTimeDbContextFactory<TechnicalAnalysisDbContext>
|
||||||
|
{
|
||||||
|
public TechnicalAnalysisDbContext CreateDbContext(string[] args)
|
||||||
|
{
|
||||||
|
var optionsBuilder = new DbContextOptionsBuilder<TechnicalAnalysisDbContext>();
|
||||||
|
optionsBuilder.UseNpgsql("Host=localhost;Database=finlytic_ta;Username=postgres;Password=postgres");
|
||||||
|
return new TechnicalAnalysisDbContext(optionsBuilder.Options);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
FROM mcr.microsoft.com/dotnet/runtime:10.0 AS base
|
||||||
|
USER $APP_UID
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
|
||||||
|
ARG BUILD_CONFIGURATION=Release
|
||||||
|
WORKDIR /src
|
||||||
|
COPY ["FinlyticTechnicals/FinlyticTechnicals.csproj", "FinlyticTechnicals/"]
|
||||||
|
COPY ["FinlyticCore/FinlyticCore.csproj", "FinlyticCore/"]
|
||||||
|
RUN dotnet restore "FinlyticTechnicals/FinlyticTechnicals.csproj"
|
||||||
|
COPY . .
|
||||||
|
WORKDIR "/src/FinlyticTechnicals"
|
||||||
|
RUN dotnet build "./FinlyticTechnicals.csproj" -c $BUILD_CONFIGURATION -o /app/build
|
||||||
|
|
||||||
|
FROM build AS publish
|
||||||
|
ARG BUILD_CONFIGURATION=Release
|
||||||
|
RUN dotnet publish "./FinlyticTechnicals.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false
|
||||||
|
|
||||||
|
FROM base AS final
|
||||||
|
WORKDIR /app
|
||||||
|
COPY --from=publish /app/publish .
|
||||||
|
ENTRYPOINT ["dotnet", "FinlyticTechnicals.dll"]
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
using System;
|
||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
using System.ComponentModel.DataAnnotations.Schema;
|
||||||
|
|
||||||
|
namespace FinlyticTechnicals.Entities;
|
||||||
|
|
||||||
|
[Table("fta_candles")]
|
||||||
|
public class FtaCandleEntity
|
||||||
|
{
|
||||||
|
[Key]
|
||||||
|
[DatabaseGenerated(DatabaseGeneratedOption.Identity)]
|
||||||
|
public long Id { get; set; }
|
||||||
|
|
||||||
|
[Required]
|
||||||
|
[MaxLength(20)]
|
||||||
|
public string Isin { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[MaxLength(30)]
|
||||||
|
public string Symbol { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[Required]
|
||||||
|
[MaxLength(10)]
|
||||||
|
public string Timeframe { get; set; } = "15m";
|
||||||
|
|
||||||
|
[Required]
|
||||||
|
public DateTime TimestampUtc { get; set; }
|
||||||
|
|
||||||
|
[Column(TypeName = "decimal(18,4)")]
|
||||||
|
public decimal Open { get; set; }
|
||||||
|
|
||||||
|
[Column(TypeName = "decimal(18,4)")]
|
||||||
|
public decimal High { get; set; }
|
||||||
|
|
||||||
|
[Column(TypeName = "decimal(18,4)")]
|
||||||
|
public decimal Low { get; set; }
|
||||||
|
|
||||||
|
[Column(TypeName = "decimal(18,4)")]
|
||||||
|
public decimal Close { get; set; }
|
||||||
|
|
||||||
|
public long Volume { get; set; }
|
||||||
|
|
||||||
|
[Column(TypeName = "decimal(18,4)")]
|
||||||
|
public decimal? Bid { get; set; }
|
||||||
|
|
||||||
|
[Column(TypeName = "decimal(18,4)")]
|
||||||
|
public decimal? Ask { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
using System.ComponentModel.DataAnnotations.Schema;
|
||||||
|
|
||||||
|
namespace FinlyticTechnicals.Entities;
|
||||||
|
|
||||||
|
[Table("fta_detected_patterns")]
|
||||||
|
public class FtaDetectedPatternEntity
|
||||||
|
{
|
||||||
|
[Key]
|
||||||
|
public Guid Id { get; set; } = Guid.NewGuid();
|
||||||
|
|
||||||
|
[Required]
|
||||||
|
[MaxLength(20)]
|
||||||
|
public string Isin { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[MaxLength(10)]
|
||||||
|
public string Timeframe { get; set; } = "15m";
|
||||||
|
|
||||||
|
[Required]
|
||||||
|
[MaxLength(50)]
|
||||||
|
public string PatternType { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[MaxLength(30)]
|
||||||
|
public string Category { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[MaxLength(20)]
|
||||||
|
public string Bias { get; set; } = "Neutral";
|
||||||
|
|
||||||
|
[MaxLength(100)]
|
||||||
|
public string Name { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[Column(TypeName = "decimal(18,4)")]
|
||||||
|
public decimal KeyPriceLevel { get; set; }
|
||||||
|
|
||||||
|
[Column(TypeName = "decimal(18,4)")]
|
||||||
|
public decimal UpperBoundary { get; set; }
|
||||||
|
|
||||||
|
[Column(TypeName = "decimal(18,4)")]
|
||||||
|
public decimal LowerBoundary { get; set; }
|
||||||
|
|
||||||
|
[Column(TypeName = "decimal(18,4)")]
|
||||||
|
public decimal InvalidationLevel { get; set; }
|
||||||
|
|
||||||
|
[Column(TypeName = "decimal(6,2)")]
|
||||||
|
public decimal QualityScore { get; set; }
|
||||||
|
|
||||||
|
public string Description { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public Dictionary<string, object>? ExtraData { get; set; }
|
||||||
|
|
||||||
|
[Required]
|
||||||
|
public DateTime DetectedAtUtc { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
using System;
|
||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
using System.ComponentModel.DataAnnotations.Schema;
|
||||||
|
|
||||||
|
namespace FinlyticTechnicals.Entities;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Persisted backing store for <c>TechnicalUniverseManager</c>'s continuously-scanned asset universe (one row
|
||||||
|
/// per monitored ISIN). Deliberately NOT meant to survive a service restart - <c>Program.cs</c> clears this
|
||||||
|
/// table on every startup, since the universe is fully rebuilt within minutes from
|
||||||
|
/// <c>RefreshFavoritesAsync</c>/<c>RefreshDiscoveryAsync</c> and fresh sentiment-spike events, and a stale row
|
||||||
|
/// that never got TTL-swept because the process was down is worse than starting from an empty universe.
|
||||||
|
/// </summary>
|
||||||
|
[Table("fta_monitored_universe_assets")]
|
||||||
|
public class FtaMonitoredUniverseAssetEntity
|
||||||
|
{
|
||||||
|
[Key]
|
||||||
|
[MaxLength(20)]
|
||||||
|
public string Isin { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[MaxLength(30)]
|
||||||
|
public string? Symbol { get; set; }
|
||||||
|
|
||||||
|
/// <summary>String form of <c>FinlyticCore.Dtos.TechnicalAnalysis.UniverseSource</c>.</summary>
|
||||||
|
[Required]
|
||||||
|
[MaxLength(20)]
|
||||||
|
public string Source { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>Lower value = higher scan priority (SentimentSpike=1, UserFavorite=2, Discovery=3).</summary>
|
||||||
|
public int Priority { get; set; }
|
||||||
|
|
||||||
|
[Required]
|
||||||
|
public DateTime AddedAtUtc { get; set; } = DateTime.UtcNow;
|
||||||
|
|
||||||
|
/// <summary><see langword="null"/> for favorites/discovery entries, which never expire by TTL.</summary>
|
||||||
|
public DateTime? ExpiresAtUtc { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
using System.ComponentModel.DataAnnotations.Schema;
|
||||||
|
using FinlyticCore.Dtos.TechnicalAnalysis;
|
||||||
|
|
||||||
|
namespace FinlyticTechnicals.Entities;
|
||||||
|
|
||||||
|
[Table("fta_technical_setups")]
|
||||||
|
public class FtaTechnicalSetupEntity
|
||||||
|
{
|
||||||
|
[Key]
|
||||||
|
public Guid SetupId { get; set; } = Guid.NewGuid();
|
||||||
|
|
||||||
|
[Required]
|
||||||
|
[MaxLength(20)]
|
||||||
|
public string Isin { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[MaxLength(30)]
|
||||||
|
public string Symbol { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[Required]
|
||||||
|
[MaxLength(10)]
|
||||||
|
public string Timeframe { get; set; } = "15m";
|
||||||
|
|
||||||
|
[Required]
|
||||||
|
[MaxLength(50)]
|
||||||
|
public string StrategyKey { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[MaxLength(100)]
|
||||||
|
public string StrategyName { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[MaxLength(10)]
|
||||||
|
public string Direction { get; set; } = "Buy";
|
||||||
|
|
||||||
|
[Column(TypeName = "decimal(6,2)")]
|
||||||
|
public decimal QualityScore { get; set; }
|
||||||
|
|
||||||
|
[Column(TypeName = "decimal(18,4)")]
|
||||||
|
public decimal CurrentPrice { get; set; }
|
||||||
|
|
||||||
|
[Column(TypeName = "decimal(18,4)")]
|
||||||
|
public decimal EntryPrice { get; set; }
|
||||||
|
|
||||||
|
[Column(TypeName = "decimal(18,4)")]
|
||||||
|
public decimal InvalidationPrice { get; set; }
|
||||||
|
|
||||||
|
[Column(TypeName = "decimal(18,4)")]
|
||||||
|
public decimal CurrentAtr { get; set; }
|
||||||
|
|
||||||
|
[Column(TypeName = "decimal(8,2)")]
|
||||||
|
public decimal EstimatedRiskRewardRatio { get; set; }
|
||||||
|
|
||||||
|
public ExitPlan ExitPlan { get; set; } = null!;
|
||||||
|
|
||||||
|
public string TechnicalRationale { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public List<PatternResultDto> TriggeringPatterns { get; set; } = [];
|
||||||
|
|
||||||
|
public Dictionary<string, decimal> IndicatorSnapshot { get; set; } = [];
|
||||||
|
|
||||||
|
public bool IsTopPick { get; set; }
|
||||||
|
|
||||||
|
[MaxLength(5)]
|
||||||
|
public string Rating { get; set; } = "B";
|
||||||
|
|
||||||
|
public bool IsActive { get; set; } = true;
|
||||||
|
|
||||||
|
[Required]
|
||||||
|
public DateTime CreatedAtUtc { get; set; } = DateTime.UtcNow;
|
||||||
|
|
||||||
|
[Required]
|
||||||
|
public DateTime ExpiresAtUtc { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// String form of the <c>UniverseSource</c> this ISIN was being monitored under when this setup was
|
||||||
|
/// computed (favorite/discovery/sentiment-spike), or <see langword="null"/> for an ad hoc analysis (e.g. a
|
||||||
|
/// manual "Analyze now" call for an ISIN not currently in the scan universe). See
|
||||||
|
/// <c>FinlyticCore.Dtos.TechnicalAnalysis.StrategyResultDto.UniverseSource</c>.
|
||||||
|
/// </summary>
|
||||||
|
[MaxLength(20)]
|
||||||
|
public string? UniverseSource { get; set; }
|
||||||
|
|
||||||
|
/// <summary>When the ISIN above entered that scan universe, alongside <see cref="UniverseSource"/>.</summary>
|
||||||
|
public DateTime? UniverseEnteredAtUtc { get; set; }
|
||||||
|
|
||||||
|
/// <summary>String form of the <c>MarketRegime</c> at analysis time. See <c>StrategyResultDto.Regime</c>.</summary>
|
||||||
|
[MaxLength(30)]
|
||||||
|
public string? Regime { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk.Worker">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.9" />
|
||||||
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.9">
|
||||||
|
<PrivateAssets>all</PrivateAssets>
|
||||||
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
|
</PackageReference>
|
||||||
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="10.0.9">
|
||||||
|
<PrivateAssets>all</PrivateAssets>
|
||||||
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
|
</PackageReference>
|
||||||
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="10.0.9" />
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Hosting" Version="10.0.1" />
|
||||||
|
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.2" />
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Http" Version="10.0.1" />
|
||||||
|
<PackageReference Include="Skender.Stock.Indicators" Version="2.7.3" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\FinlyticCore\FinlyticCore.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using FinlyticCore.Dtos.TechnicalAnalysis;
|
||||||
|
|
||||||
|
namespace FinlyticTechnicals.Indicators;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Standard OHLCV rollup of a finer-grained, chronologically ordered candle series into coarser buckets
|
||||||
|
/// (first Open, max High, min Low, last Close, summed Volume). Shared by the live ring-buffer aggregator
|
||||||
|
/// (<c>MultiTimeframeCandleAggregator</c>, which previously duplicated this exact bucketing logic per
|
||||||
|
/// timeframe) and backtest replay (<c>FinlyticSimulation.Engine.HistoricalReplayRunner</c>, which previously
|
||||||
|
/// had no way to derive a higher timeframe at all - see its own doc comment) so both paths compute higher
|
||||||
|
/// timeframes identically instead of maintaining two separate implementations.
|
||||||
|
/// </summary>
|
||||||
|
public static class CandleResampler
|
||||||
|
{
|
||||||
|
/// <summary>Bucket size in minutes for every timeframe name known across FinlyticTechnicals/FinlyticSimulation.</summary>
|
||||||
|
public static readonly IReadOnlyDictionary<string, int> KnownTimeframeMinutes =
|
||||||
|
new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase)
|
||||||
|
{
|
||||||
|
["1m"] = 1,
|
||||||
|
["5m"] = 5,
|
||||||
|
["15m"] = 15,
|
||||||
|
["1h"] = 60,
|
||||||
|
["1d"] = 1440
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>Every known timeframe strictly coarser than <paramref name="baseMinutes"/>, ascending.</summary>
|
||||||
|
public static IEnumerable<(string Timeframe, int Minutes)> CoarserTimeframes(int baseMinutes) =>
|
||||||
|
KnownTimeframeMinutes
|
||||||
|
.Where(kv => kv.Value > baseMinutes)
|
||||||
|
.OrderBy(kv => kv.Value)
|
||||||
|
.Select(kv => (kv.Key, kv.Value));
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Aggregates <paramref name="source"/> into <paramref name="bucketMinutes"/>-wide bars. Returns an empty
|
||||||
|
/// list (never fabricates a partial/synthetic bar) if <paramref name="source"/> is empty.
|
||||||
|
/// </summary>
|
||||||
|
public static List<CandleDto> Resample(IReadOnlyList<CandleDto> source, int bucketMinutes)
|
||||||
|
{
|
||||||
|
if (source.Count == 0 || bucketMinutes <= 0) return [];
|
||||||
|
|
||||||
|
var groups = source
|
||||||
|
.GroupBy(c => BucketStart(c.Timestamp, bucketMinutes))
|
||||||
|
.OrderBy(g => g.Key);
|
||||||
|
|
||||||
|
var result = new List<CandleDto>();
|
||||||
|
foreach (var group in groups)
|
||||||
|
{
|
||||||
|
var bars = group.OrderBy(b => b.Timestamp).ToList();
|
||||||
|
if (bars.Count == 0) continue;
|
||||||
|
|
||||||
|
result.Add(new CandleDto(
|
||||||
|
Timestamp: group.Key,
|
||||||
|
Open: bars[0].Open,
|
||||||
|
High: bars.Max(b => b.High),
|
||||||
|
Low: bars.Min(b => b.Low),
|
||||||
|
Close: bars[^1].Close,
|
||||||
|
Volume: bars.Sum(b => b.Volume),
|
||||||
|
Bid: bars[^1].Bid,
|
||||||
|
Ask: bars[^1].Ask
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static DateTime BucketStart(DateTime timestamp, int bucketMinutes)
|
||||||
|
{
|
||||||
|
var dayStart = new DateTime(timestamp.Year, timestamp.Month, timestamp.Day, 0, 0, 0, DateTimeKind.Utc);
|
||||||
|
if (bucketMinutes >= 1440) return dayStart;
|
||||||
|
|
||||||
|
int totalMinutes = timestamp.Hour * 60 + timestamp.Minute;
|
||||||
|
int bucketed = (totalMinutes / bucketMinutes) * bucketMinutes;
|
||||||
|
return dayStart.AddMinutes(bucketed);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,317 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using FinlyticCore.Dtos.TechnicalAnalysis;
|
||||||
|
|
||||||
|
namespace FinlyticTechnicals.Indicators;
|
||||||
|
|
||||||
|
public record MacdResult(
|
||||||
|
decimal MacdLine,
|
||||||
|
decimal SignalLine,
|
||||||
|
decimal Histogram
|
||||||
|
);
|
||||||
|
|
||||||
|
public record BollingerBandsResult(
|
||||||
|
decimal UpperBand,
|
||||||
|
decimal MiddleBand,
|
||||||
|
decimal LowerBand,
|
||||||
|
decimal Bandwidth,
|
||||||
|
decimal PercentB
|
||||||
|
);
|
||||||
|
|
||||||
|
public record KeltnerChannelResult(
|
||||||
|
decimal UpperBand,
|
||||||
|
decimal MiddleBand,
|
||||||
|
decimal LowerBand
|
||||||
|
);
|
||||||
|
|
||||||
|
public record SuperTrendResult(
|
||||||
|
decimal Value,
|
||||||
|
SignalDirection Direction,
|
||||||
|
bool IsFlipped
|
||||||
|
);
|
||||||
|
|
||||||
|
public record SqueezeResult(
|
||||||
|
bool IsInSqueeze,
|
||||||
|
decimal MomentumHistogram,
|
||||||
|
string SqueezeState // "ON", "FIRED_BULLISH", "FIRED_BEARISH", "NONE"
|
||||||
|
);
|
||||||
|
|
||||||
|
public record AdxResult(
|
||||||
|
decimal Adx,
|
||||||
|
decimal PlusDi,
|
||||||
|
decimal MinusDi,
|
||||||
|
bool IsTrending
|
||||||
|
);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// High-performance mathematical indicators engine for time-series analysis.
|
||||||
|
/// </summary>
|
||||||
|
public static class TechnicalIndicatorsEngine
|
||||||
|
{
|
||||||
|
public static decimal CalculateSma(IReadOnlyList<CandleDto> candles, int period)
|
||||||
|
{
|
||||||
|
if (candles == null || candles.Count < period || period <= 0) return 0m;
|
||||||
|
decimal sum = 0m;
|
||||||
|
for (int i = candles.Count - period; i < candles.Count; i++)
|
||||||
|
{
|
||||||
|
sum += candles[i].Close;
|
||||||
|
}
|
||||||
|
return sum / period;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static decimal CalculateEma(IReadOnlyList<CandleDto> candles, int period)
|
||||||
|
{
|
||||||
|
if (candles == null || candles.Count == 0 || period <= 0) return 0m;
|
||||||
|
if (candles.Count < period) return CalculateSma(candles, candles.Count);
|
||||||
|
|
||||||
|
decimal k = 2m / (period + 1);
|
||||||
|
// Seed with SMA
|
||||||
|
decimal ema = 0m;
|
||||||
|
for (int i = 0; i < period; i++)
|
||||||
|
{
|
||||||
|
ema += candles[i].Close;
|
||||||
|
}
|
||||||
|
ema /= period;
|
||||||
|
|
||||||
|
for (int i = period; i < candles.Count; i++)
|
||||||
|
{
|
||||||
|
ema = (candles[i].Close * k) + (ema * (1m - k));
|
||||||
|
}
|
||||||
|
return ema;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static decimal CalculateRsi(IReadOnlyList<CandleDto> candles, int period = 14)
|
||||||
|
{
|
||||||
|
if (candles == null || candles.Count <= period || period <= 0) return 50m;
|
||||||
|
|
||||||
|
decimal gains = 0m;
|
||||||
|
decimal losses = 0m;
|
||||||
|
|
||||||
|
for (int i = 1; i <= period; i++)
|
||||||
|
{
|
||||||
|
decimal diff = candles[i].Close - candles[i - 1].Close;
|
||||||
|
if (diff >= 0) gains += diff;
|
||||||
|
else losses += Math.Abs(diff);
|
||||||
|
}
|
||||||
|
|
||||||
|
decimal avgGain = gains / period;
|
||||||
|
decimal avgLoss = losses / period;
|
||||||
|
|
||||||
|
for (int i = period + 1; i < candles.Count; i++)
|
||||||
|
{
|
||||||
|
decimal diff = candles[i].Close - candles[i - 1].Close;
|
||||||
|
if (diff >= 0)
|
||||||
|
{
|
||||||
|
avgGain = ((avgGain * (period - 1)) + diff) / period;
|
||||||
|
avgLoss = (avgLoss * (period - 1)) / period;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
avgGain = (avgGain * (period - 1)) / period;
|
||||||
|
avgLoss = ((avgLoss * (period - 1)) + Math.Abs(diff)) / period;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (avgLoss == 0m) return 100m;
|
||||||
|
decimal rs = avgGain / avgLoss;
|
||||||
|
return 100m - (100m / (1m + rs));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static decimal CalculateAtr(IReadOnlyList<CandleDto> candles, int period = 14)
|
||||||
|
{
|
||||||
|
if (candles == null || candles.Count < 2 || period <= 0) return 0m;
|
||||||
|
int count = candles.Count;
|
||||||
|
int effectivePeriod = Math.Min(period, count - 1);
|
||||||
|
|
||||||
|
decimal trSum = 0m;
|
||||||
|
for (int i = count - effectivePeriod; i < count; i++)
|
||||||
|
{
|
||||||
|
decimal high = candles[i].High;
|
||||||
|
decimal low = candles[i].Low;
|
||||||
|
decimal prevClose = candles[i - 1].Close;
|
||||||
|
|
||||||
|
decimal tr = Math.Max(high - low, Math.Max(Math.Abs(high - prevClose), Math.Abs(low - prevClose)));
|
||||||
|
trSum += tr;
|
||||||
|
}
|
||||||
|
|
||||||
|
return trSum / effectivePeriod;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static MacdResult CalculateMacd(IReadOnlyList<CandleDto> candles, int fastPeriod = 12, int slowPeriod = 26, int signalPeriod = 9)
|
||||||
|
{
|
||||||
|
if (candles == null || candles.Count < slowPeriod)
|
||||||
|
return new MacdResult(0m, 0m, 0m);
|
||||||
|
|
||||||
|
decimal fastEma = CalculateEma(candles, fastPeriod);
|
||||||
|
decimal slowEma = CalculateEma(candles, slowPeriod);
|
||||||
|
decimal macdLine = fastEma - slowEma;
|
||||||
|
|
||||||
|
// Calculate series of MACD lines for signal line calculation
|
||||||
|
var macdHistory = new List<CandleDto>();
|
||||||
|
int start = Math.Max(0, candles.Count - (signalPeriod + 5));
|
||||||
|
for (int i = start; i < candles.Count; i++)
|
||||||
|
{
|
||||||
|
var subCandles = candles.Take(i + 1).ToList();
|
||||||
|
if (subCandles.Count >= slowPeriod)
|
||||||
|
{
|
||||||
|
var f = CalculateEma(subCandles, fastPeriod);
|
||||||
|
var s = CalculateEma(subCandles, slowPeriod);
|
||||||
|
var val = f - s;
|
||||||
|
macdHistory.Add(new CandleDto(candles[i].Timestamp, val, val, val, val, 0));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
decimal signalLine = macdHistory.Count >= signalPeriod
|
||||||
|
? CalculateEma(macdHistory, signalPeriod)
|
||||||
|
: macdLine;
|
||||||
|
|
||||||
|
decimal histogram = macdLine - signalLine;
|
||||||
|
return new MacdResult(macdLine, signalLine, histogram);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static BollingerBandsResult CalculateBollingerBands(IReadOnlyList<CandleDto> candles, int period = 20, decimal multiplier = 2.0m)
|
||||||
|
{
|
||||||
|
if (candles == null || candles.Count < period || period <= 0)
|
||||||
|
return new BollingerBandsResult(0m, 0m, 0m, 0m, 0m);
|
||||||
|
|
||||||
|
decimal sma = CalculateSma(candles, period);
|
||||||
|
|
||||||
|
decimal sumSquares = 0m;
|
||||||
|
for (int i = candles.Count - period; i < candles.Count; i++)
|
||||||
|
{
|
||||||
|
decimal diff = candles[i].Close - sma;
|
||||||
|
sumSquares += diff * diff;
|
||||||
|
}
|
||||||
|
decimal stdDev = (decimal)Math.Sqrt((double)(sumSquares / period));
|
||||||
|
|
||||||
|
decimal upper = sma + (multiplier * stdDev);
|
||||||
|
decimal lower = sma - (multiplier * stdDev);
|
||||||
|
decimal bandwidth = sma > 0 ? ((upper - lower) / sma) * 100m : 0m;
|
||||||
|
decimal currentClose = candles.Last().Close;
|
||||||
|
decimal percentB = (upper - lower) > 0 ? (currentClose - lower) / (upper - lower) : 0.5m;
|
||||||
|
|
||||||
|
return new BollingerBandsResult(upper, sma, lower, bandwidth, percentB);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static KeltnerChannelResult CalculateKeltnerChannels(IReadOnlyList<CandleDto> candles, int period = 20, decimal atrMultiplier = 1.5m)
|
||||||
|
{
|
||||||
|
if (candles == null || candles.Count < period)
|
||||||
|
return new KeltnerChannelResult(0m, 0m, 0m);
|
||||||
|
|
||||||
|
decimal ema = CalculateEma(candles, period);
|
||||||
|
decimal atr = CalculateAtr(candles, period);
|
||||||
|
|
||||||
|
decimal upper = ema + (atrMultiplier * atr);
|
||||||
|
decimal lower = ema - (atrMultiplier * atr);
|
||||||
|
|
||||||
|
return new KeltnerChannelResult(upper, ema, lower);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static SqueezeResult CalculateVolatilitySqueeze(IReadOnlyList<CandleDto> candles)
|
||||||
|
{
|
||||||
|
var bb = CalculateBollingerBands(candles, 20, 2.0m);
|
||||||
|
var kc = CalculateKeltnerChannels(candles, 20, 1.5m);
|
||||||
|
|
||||||
|
bool inSqueeze = bb.LowerBand > kc.LowerBand && bb.UpperBand < kc.UpperBand;
|
||||||
|
|
||||||
|
var macd = CalculateMacd(candles, 12, 26, 9);
|
||||||
|
decimal momentum = macd.Histogram;
|
||||||
|
|
||||||
|
string state = "NONE";
|
||||||
|
if (inSqueeze)
|
||||||
|
{
|
||||||
|
state = "ON";
|
||||||
|
}
|
||||||
|
else if (momentum > 0)
|
||||||
|
{
|
||||||
|
state = "FIRED_BULLISH";
|
||||||
|
}
|
||||||
|
else if (momentum < 0)
|
||||||
|
{
|
||||||
|
state = "FIRED_BEARISH";
|
||||||
|
}
|
||||||
|
|
||||||
|
return new SqueezeResult(inSqueeze, momentum, state);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static SuperTrendResult CalculateSuperTrend(IReadOnlyList<CandleDto> candles, int period = 10, decimal multiplier = 3.0m)
|
||||||
|
{
|
||||||
|
if (candles == null || candles.Count < period)
|
||||||
|
return new SuperTrendResult(0m, SignalDirection.Neutral, false);
|
||||||
|
|
||||||
|
decimal atr = CalculateAtr(candles, period);
|
||||||
|
var last = candles.Last();
|
||||||
|
decimal hl2 = (last.High + last.Low) / 2m;
|
||||||
|
|
||||||
|
decimal basicUpperBand = hl2 + (multiplier * atr);
|
||||||
|
decimal basicLowerBand = hl2 - (multiplier * atr);
|
||||||
|
|
||||||
|
// Determine trend relative to previous candle
|
||||||
|
decimal prevClose = candles.Count > 1 ? candles[^2].Close : last.Close;
|
||||||
|
SignalDirection dir = last.Close > basicUpperBand ? SignalDirection.Buy :
|
||||||
|
last.Close < basicLowerBand ? SignalDirection.Sell :
|
||||||
|
(last.Close >= prevClose ? SignalDirection.Buy : SignalDirection.Sell);
|
||||||
|
|
||||||
|
decimal superTrendValue = dir == SignalDirection.Buy ? basicLowerBand : basicUpperBand;
|
||||||
|
bool isFlipped = (prevClose < basicUpperBand && last.Close > basicUpperBand) ||
|
||||||
|
(prevClose > basicLowerBand && last.Close < basicLowerBand);
|
||||||
|
|
||||||
|
return new SuperTrendResult(superTrendValue, dir, isFlipped);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static AdxResult CalculateAdx(IReadOnlyList<CandleDto> candles, int period = 14)
|
||||||
|
{
|
||||||
|
if (candles == null || candles.Count <= period * 2)
|
||||||
|
return new AdxResult(15m, 15m, 15m, false);
|
||||||
|
|
||||||
|
decimal trSum = 0m;
|
||||||
|
decimal plusDmSum = 0m;
|
||||||
|
decimal minusDmSum = 0m;
|
||||||
|
|
||||||
|
for (int i = candles.Count - period; i < candles.Count; i++)
|
||||||
|
{
|
||||||
|
var curr = candles[i];
|
||||||
|
var prev = candles[i - 1];
|
||||||
|
|
||||||
|
decimal upMove = curr.High - prev.High;
|
||||||
|
decimal downMove = prev.Low - curr.Low;
|
||||||
|
|
||||||
|
decimal plusDm = (upMove > downMove && upMove > 0) ? upMove : 0m;
|
||||||
|
decimal minusDm = (downMove > upMove && downMove > 0) ? downMove : 0m;
|
||||||
|
|
||||||
|
decimal tr = Math.Max(curr.High - curr.Low, Math.Max(Math.Abs(curr.High - prev.Close), Math.Abs(curr.Low - prev.Close)));
|
||||||
|
|
||||||
|
trSum += tr;
|
||||||
|
plusDmSum += plusDm;
|
||||||
|
minusDmSum += minusDm;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (trSum == 0m) return new AdxResult(0m, 0m, 0m, false);
|
||||||
|
|
||||||
|
decimal plusDi = (plusDmSum / trSum) * 100m;
|
||||||
|
decimal minusDi = (minusDmSum / trSum) * 100m;
|
||||||
|
decimal diSum = plusDi + minusDi;
|
||||||
|
decimal dx = diSum > 0 ? (Math.Abs(plusDi - minusDi) / diSum) * 100m : 0m;
|
||||||
|
|
||||||
|
bool isTrending = dx >= 25m;
|
||||||
|
return new AdxResult(dx, plusDi, minusDi, isTrending);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static decimal CalculateVwap(IReadOnlyList<CandleDto> candles)
|
||||||
|
{
|
||||||
|
if (candles == null || candles.Count == 0) return 0m;
|
||||||
|
|
||||||
|
decimal totalTypicalPriceVolume = 0m;
|
||||||
|
long totalVolume = 0;
|
||||||
|
|
||||||
|
foreach (var c in candles)
|
||||||
|
{
|
||||||
|
decimal typicalPrice = (c.High + c.Low + c.Close) / 3m;
|
||||||
|
totalTypicalPriceVolume += typicalPrice * c.Volume;
|
||||||
|
totalVolume += c.Volume;
|
||||||
|
}
|
||||||
|
|
||||||
|
return totalVolume > 0 ? totalTypicalPriceVolume / totalVolume : candles.Last().Close;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,289 @@
|
|||||||
|
// <auto-generated />
|
||||||
|
using System;
|
||||||
|
using FinlyticTechnicals.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 FinlyticTechnicals.Migrations
|
||||||
|
{
|
||||||
|
[DbContext(typeof(TechnicalAnalysisDbContext))]
|
||||||
|
[Migration("20260819182054_Init")]
|
||||||
|
partial class Init
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
#pragma warning disable 612, 618
|
||||||
|
modelBuilder
|
||||||
|
.HasAnnotation("ProductVersion", "10.0.9")
|
||||||
|
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||||
|
|
||||||
|
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||||
|
|
||||||
|
modelBuilder.Entity("FinlyticCore.Entities.Settings.SettingEntity", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("Key")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(150)
|
||||||
|
.HasColumnType("character varying(150)");
|
||||||
|
|
||||||
|
b.Property<DateTime>("LastUpdatedUtc")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("ServiceIdentifier")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("character varying(100)");
|
||||||
|
|
||||||
|
b.Property<string>("ValueJson")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("Key")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("DynamicSettings");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("FinlyticTechnicals.Entities.FtaCandleEntity", b =>
|
||||||
|
{
|
||||||
|
b.Property<long>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("bigint");
|
||||||
|
|
||||||
|
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
||||||
|
|
||||||
|
b.Property<decimal?>("Ask")
|
||||||
|
.HasColumnType("decimal(18,4)");
|
||||||
|
|
||||||
|
b.Property<decimal?>("Bid")
|
||||||
|
.HasColumnType("decimal(18,4)");
|
||||||
|
|
||||||
|
b.Property<decimal>("Close")
|
||||||
|
.HasColumnType("decimal(18,4)");
|
||||||
|
|
||||||
|
b.Property<decimal>("High")
|
||||||
|
.HasColumnType("decimal(18,4)");
|
||||||
|
|
||||||
|
b.Property<string>("Isin")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(20)
|
||||||
|
.HasColumnType("character varying(20)");
|
||||||
|
|
||||||
|
b.Property<decimal>("Low")
|
||||||
|
.HasColumnType("decimal(18,4)");
|
||||||
|
|
||||||
|
b.Property<decimal>("Open")
|
||||||
|
.HasColumnType("decimal(18,4)");
|
||||||
|
|
||||||
|
b.Property<string>("Symbol")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(30)
|
||||||
|
.HasColumnType("character varying(30)");
|
||||||
|
|
||||||
|
b.Property<string>("Timeframe")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(10)
|
||||||
|
.HasColumnType("character varying(10)");
|
||||||
|
|
||||||
|
b.Property<DateTime>("TimestampUtc")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<long>("Volume")
|
||||||
|
.HasColumnType("bigint");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("TimestampUtc");
|
||||||
|
|
||||||
|
b.HasIndex("Isin", "Timeframe", "TimestampUtc");
|
||||||
|
|
||||||
|
b.ToTable("fta_candles");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("FinlyticTechnicals.Entities.FtaDetectedPatternEntity", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("Bias")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(20)
|
||||||
|
.HasColumnType("character varying(20)");
|
||||||
|
|
||||||
|
b.Property<string>("Category")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(30)
|
||||||
|
.HasColumnType("character varying(30)");
|
||||||
|
|
||||||
|
b.Property<string>("Description")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<DateTime>("DetectedAtUtc")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("ExtraData")
|
||||||
|
.HasColumnType("jsonb");
|
||||||
|
|
||||||
|
b.Property<decimal>("InvalidationLevel")
|
||||||
|
.HasColumnType("decimal(18,4)");
|
||||||
|
|
||||||
|
b.Property<string>("Isin")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(20)
|
||||||
|
.HasColumnType("character varying(20)");
|
||||||
|
|
||||||
|
b.Property<decimal>("KeyPriceLevel")
|
||||||
|
.HasColumnType("decimal(18,4)");
|
||||||
|
|
||||||
|
b.Property<decimal>("LowerBoundary")
|
||||||
|
.HasColumnType("decimal(18,4)");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("character varying(100)");
|
||||||
|
|
||||||
|
b.Property<string>("PatternType")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(50)
|
||||||
|
.HasColumnType("character varying(50)");
|
||||||
|
|
||||||
|
b.Property<decimal>("QualityScore")
|
||||||
|
.HasColumnType("decimal(6,2)");
|
||||||
|
|
||||||
|
b.Property<string>("Timeframe")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(10)
|
||||||
|
.HasColumnType("character varying(10)");
|
||||||
|
|
||||||
|
b.Property<decimal>("UpperBoundary")
|
||||||
|
.HasColumnType("decimal(18,4)");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("PatternType");
|
||||||
|
|
||||||
|
b.HasIndex("QualityScore");
|
||||||
|
|
||||||
|
b.HasIndex("Isin", "DetectedAtUtc");
|
||||||
|
|
||||||
|
b.ToTable("fta_detected_patterns");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("FinlyticTechnicals.Entities.FtaTechnicalSetupEntity", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("SetupId")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAtUtc")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<decimal>("CurrentAtr")
|
||||||
|
.HasColumnType("decimal(18,4)");
|
||||||
|
|
||||||
|
b.Property<decimal>("CurrentPrice")
|
||||||
|
.HasColumnType("decimal(18,4)");
|
||||||
|
|
||||||
|
b.Property<string>("Direction")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(10)
|
||||||
|
.HasColumnType("character varying(10)");
|
||||||
|
|
||||||
|
b.Property<decimal>("EntryPrice")
|
||||||
|
.HasColumnType("decimal(18,4)");
|
||||||
|
|
||||||
|
b.Property<decimal>("EstimatedRiskRewardRatio")
|
||||||
|
.HasColumnType("decimal(8,2)");
|
||||||
|
|
||||||
|
b.Property<string>("ExitPlan")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("jsonb");
|
||||||
|
|
||||||
|
b.Property<DateTime>("ExpiresAtUtc")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("IndicatorSnapshot")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("jsonb");
|
||||||
|
|
||||||
|
b.Property<decimal>("InvalidationPrice")
|
||||||
|
.HasColumnType("decimal(18,4)");
|
||||||
|
|
||||||
|
b.Property<bool>("IsActive")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<bool>("IsTopPick")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<string>("Isin")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(20)
|
||||||
|
.HasColumnType("character varying(20)");
|
||||||
|
|
||||||
|
b.Property<decimal>("QualityScore")
|
||||||
|
.HasColumnType("decimal(6,2)");
|
||||||
|
|
||||||
|
b.Property<string>("Rating")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(5)
|
||||||
|
.HasColumnType("character varying(5)");
|
||||||
|
|
||||||
|
b.Property<string>("StrategyKey")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(50)
|
||||||
|
.HasColumnType("character varying(50)");
|
||||||
|
|
||||||
|
b.Property<string>("StrategyName")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("character varying(100)");
|
||||||
|
|
||||||
|
b.Property<string>("Symbol")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(30)
|
||||||
|
.HasColumnType("character varying(30)");
|
||||||
|
|
||||||
|
b.Property<string>("TechnicalRationale")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("Timeframe")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(10)
|
||||||
|
.HasColumnType("character varying(10)");
|
||||||
|
|
||||||
|
b.Property<string>("TriggeringPatterns")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("jsonb");
|
||||||
|
|
||||||
|
b.HasKey("SetupId");
|
||||||
|
|
||||||
|
b.HasIndex("CreatedAtUtc");
|
||||||
|
|
||||||
|
b.HasIndex("IsTopPick");
|
||||||
|
|
||||||
|
b.HasIndex("QualityScore");
|
||||||
|
|
||||||
|
b.HasIndex("Isin", "IsActive", "ExpiresAtUtc");
|
||||||
|
|
||||||
|
b.ToTable("fta_technical_setups");
|
||||||
|
});
|
||||||
|
#pragma warning restore 612, 618
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace FinlyticTechnicals.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class Init : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "DynamicSettings",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
Key = table.Column<string>(type: "character varying(150)", maxLength: 150, nullable: false),
|
||||||
|
ValueJson = table.Column<string>(type: "text", nullable: false),
|
||||||
|
ServiceIdentifier = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
|
||||||
|
LastUpdatedUtc = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_DynamicSettings", x => x.Id);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "fta_candles",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<long>(type: "bigint", nullable: false)
|
||||||
|
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||||
|
Isin = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false),
|
||||||
|
Symbol = table.Column<string>(type: "character varying(30)", maxLength: 30, nullable: false),
|
||||||
|
Timeframe = table.Column<string>(type: "character varying(10)", maxLength: 10, nullable: false),
|
||||||
|
TimestampUtc = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||||
|
Open = table.Column<decimal>(type: "numeric(18,4)", nullable: false),
|
||||||
|
High = table.Column<decimal>(type: "numeric(18,4)", nullable: false),
|
||||||
|
Low = table.Column<decimal>(type: "numeric(18,4)", nullable: false),
|
||||||
|
Close = table.Column<decimal>(type: "numeric(18,4)", nullable: false),
|
||||||
|
Volume = table.Column<long>(type: "bigint", nullable: false),
|
||||||
|
Bid = table.Column<decimal>(type: "numeric(18,4)", nullable: true),
|
||||||
|
Ask = table.Column<decimal>(type: "numeric(18,4)", nullable: true)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_fta_candles", x => x.Id);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "fta_detected_patterns",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
Isin = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false),
|
||||||
|
Timeframe = table.Column<string>(type: "character varying(10)", maxLength: 10, nullable: false),
|
||||||
|
PatternType = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: false),
|
||||||
|
Category = table.Column<string>(type: "character varying(30)", maxLength: 30, nullable: false),
|
||||||
|
Bias = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false),
|
||||||
|
Name = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
|
||||||
|
KeyPriceLevel = table.Column<decimal>(type: "numeric(18,4)", nullable: false),
|
||||||
|
UpperBoundary = table.Column<decimal>(type: "numeric(18,4)", nullable: false),
|
||||||
|
LowerBoundary = table.Column<decimal>(type: "numeric(18,4)", nullable: false),
|
||||||
|
InvalidationLevel = table.Column<decimal>(type: "numeric(18,4)", nullable: false),
|
||||||
|
QualityScore = table.Column<decimal>(type: "numeric(6,2)", nullable: false),
|
||||||
|
Description = table.Column<string>(type: "text", nullable: false),
|
||||||
|
ExtraData = table.Column<string>(type: "jsonb", nullable: true),
|
||||||
|
DetectedAtUtc = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_fta_detected_patterns", x => x.Id);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "fta_technical_setups",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
SetupId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
Isin = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false),
|
||||||
|
Symbol = table.Column<string>(type: "character varying(30)", maxLength: 30, nullable: false),
|
||||||
|
Timeframe = table.Column<string>(type: "character varying(10)", maxLength: 10, nullable: false),
|
||||||
|
StrategyKey = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: false),
|
||||||
|
StrategyName = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
|
||||||
|
Direction = table.Column<string>(type: "character varying(10)", maxLength: 10, nullable: false),
|
||||||
|
QualityScore = table.Column<decimal>(type: "numeric(6,2)", nullable: false),
|
||||||
|
CurrentPrice = table.Column<decimal>(type: "numeric(18,4)", nullable: false),
|
||||||
|
EntryPrice = table.Column<decimal>(type: "numeric(18,4)", nullable: false),
|
||||||
|
InvalidationPrice = table.Column<decimal>(type: "numeric(18,4)", nullable: false),
|
||||||
|
CurrentAtr = table.Column<decimal>(type: "numeric(18,4)", nullable: false),
|
||||||
|
EstimatedRiskRewardRatio = table.Column<decimal>(type: "numeric(8,2)", nullable: false),
|
||||||
|
ExitPlan = table.Column<string>(type: "jsonb", nullable: false),
|
||||||
|
TechnicalRationale = table.Column<string>(type: "text", nullable: false),
|
||||||
|
TriggeringPatterns = table.Column<string>(type: "jsonb", nullable: false),
|
||||||
|
IndicatorSnapshot = table.Column<string>(type: "jsonb", nullable: false),
|
||||||
|
IsTopPick = table.Column<bool>(type: "boolean", nullable: false),
|
||||||
|
Rating = table.Column<string>(type: "character varying(5)", maxLength: 5, nullable: false),
|
||||||
|
IsActive = table.Column<bool>(type: "boolean", nullable: false),
|
||||||
|
CreatedAtUtc = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||||
|
ExpiresAtUtc = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_fta_technical_setups", x => x.SetupId);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_DynamicSettings_Key",
|
||||||
|
table: "DynamicSettings",
|
||||||
|
column: "Key",
|
||||||
|
unique: true);
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_fta_candles_Isin_Timeframe_TimestampUtc",
|
||||||
|
table: "fta_candles",
|
||||||
|
columns: new[] { "Isin", "Timeframe", "TimestampUtc" });
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_fta_candles_TimestampUtc",
|
||||||
|
table: "fta_candles",
|
||||||
|
column: "TimestampUtc");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_fta_detected_patterns_Isin_DetectedAtUtc",
|
||||||
|
table: "fta_detected_patterns",
|
||||||
|
columns: new[] { "Isin", "DetectedAtUtc" });
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_fta_detected_patterns_PatternType",
|
||||||
|
table: "fta_detected_patterns",
|
||||||
|
column: "PatternType");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_fta_detected_patterns_QualityScore",
|
||||||
|
table: "fta_detected_patterns",
|
||||||
|
column: "QualityScore");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_fta_technical_setups_CreatedAtUtc",
|
||||||
|
table: "fta_technical_setups",
|
||||||
|
column: "CreatedAtUtc");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_fta_technical_setups_Isin_IsActive_ExpiresAtUtc",
|
||||||
|
table: "fta_technical_setups",
|
||||||
|
columns: new[] { "Isin", "IsActive", "ExpiresAtUtc" });
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_fta_technical_setups_IsTopPick",
|
||||||
|
table: "fta_technical_setups",
|
||||||
|
column: "IsTopPick");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_fta_technical_setups_QualityScore",
|
||||||
|
table: "fta_technical_setups",
|
||||||
|
column: "QualityScore");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "DynamicSettings");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "fta_candles");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "fta_detected_patterns");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "fta_technical_setups");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+291
@@ -0,0 +1,291 @@
|
|||||||
|
// <auto-generated />
|
||||||
|
using System;
|
||||||
|
using FinlyticTechnicals.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 FinlyticTechnicals.Migrations
|
||||||
|
{
|
||||||
|
[DbContext(typeof(TechnicalAnalysisDbContext))]
|
||||||
|
[Migration("20260821153627_SyncTechnicalsModelDrift")]
|
||||||
|
partial class SyncTechnicalsModelDrift
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
#pragma warning disable 612, 618
|
||||||
|
modelBuilder
|
||||||
|
.HasAnnotation("ProductVersion", "10.0.9")
|
||||||
|
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||||
|
|
||||||
|
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||||
|
|
||||||
|
modelBuilder.Entity("FinlyticCore.Entities.Settings.SettingEntity", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("Key")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(150)
|
||||||
|
.HasColumnType("character varying(150)");
|
||||||
|
|
||||||
|
b.Property<DateTime>("LastUpdatedUtc")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("ServiceIdentifier")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("character varying(100)");
|
||||||
|
|
||||||
|
b.Property<string>("ValueJson")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("Key")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("DynamicSettings");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("FinlyticTechnicals.Entities.FtaCandleEntity", b =>
|
||||||
|
{
|
||||||
|
b.Property<long>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("bigint");
|
||||||
|
|
||||||
|
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
||||||
|
|
||||||
|
b.Property<decimal?>("Ask")
|
||||||
|
.HasColumnType("decimal(18,4)");
|
||||||
|
|
||||||
|
b.Property<decimal?>("Bid")
|
||||||
|
.HasColumnType("decimal(18,4)");
|
||||||
|
|
||||||
|
b.Property<decimal>("Close")
|
||||||
|
.HasColumnType("decimal(18,4)");
|
||||||
|
|
||||||
|
b.Property<decimal>("High")
|
||||||
|
.HasColumnType("decimal(18,4)");
|
||||||
|
|
||||||
|
b.Property<string>("Isin")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(20)
|
||||||
|
.HasColumnType("character varying(20)");
|
||||||
|
|
||||||
|
b.Property<decimal>("Low")
|
||||||
|
.HasColumnType("decimal(18,4)");
|
||||||
|
|
||||||
|
b.Property<decimal>("Open")
|
||||||
|
.HasColumnType("decimal(18,4)");
|
||||||
|
|
||||||
|
b.Property<string>("Symbol")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(30)
|
||||||
|
.HasColumnType("character varying(30)");
|
||||||
|
|
||||||
|
b.Property<string>("Timeframe")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(10)
|
||||||
|
.HasColumnType("character varying(10)");
|
||||||
|
|
||||||
|
b.Property<DateTime>("TimestampUtc")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<long>("Volume")
|
||||||
|
.HasColumnType("bigint");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("TimestampUtc");
|
||||||
|
|
||||||
|
b.HasIndex("Isin", "Timeframe", "TimestampUtc");
|
||||||
|
|
||||||
|
b.ToTable("fta_candles");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("FinlyticTechnicals.Entities.FtaDetectedPatternEntity", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("Bias")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(20)
|
||||||
|
.HasColumnType("character varying(20)");
|
||||||
|
|
||||||
|
b.Property<string>("Category")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(30)
|
||||||
|
.HasColumnType("character varying(30)");
|
||||||
|
|
||||||
|
b.Property<string>("Description")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<DateTime>("DetectedAtUtc")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("ExtraData")
|
||||||
|
.HasColumnType("jsonb");
|
||||||
|
|
||||||
|
b.Property<decimal>("InvalidationLevel")
|
||||||
|
.HasColumnType("decimal(18,4)");
|
||||||
|
|
||||||
|
b.Property<string>("Isin")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(20)
|
||||||
|
.HasColumnType("character varying(20)");
|
||||||
|
|
||||||
|
b.Property<decimal>("KeyPriceLevel")
|
||||||
|
.HasColumnType("decimal(18,4)");
|
||||||
|
|
||||||
|
b.Property<decimal>("LowerBoundary")
|
||||||
|
.HasColumnType("decimal(18,4)");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("character varying(100)");
|
||||||
|
|
||||||
|
b.Property<string>("PatternType")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(50)
|
||||||
|
.HasColumnType("character varying(50)");
|
||||||
|
|
||||||
|
b.Property<decimal>("QualityScore")
|
||||||
|
.HasColumnType("decimal(6,2)");
|
||||||
|
|
||||||
|
b.Property<string>("Timeframe")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(10)
|
||||||
|
.HasColumnType("character varying(10)");
|
||||||
|
|
||||||
|
b.Property<decimal>("UpperBoundary")
|
||||||
|
.HasColumnType("decimal(18,4)");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("PatternType");
|
||||||
|
|
||||||
|
b.HasIndex("QualityScore");
|
||||||
|
|
||||||
|
b.HasIndex("Isin", "DetectedAtUtc");
|
||||||
|
|
||||||
|
b.ToTable("fta_detected_patterns");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("FinlyticTechnicals.Entities.FtaTechnicalSetupEntity", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("SetupId")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAtUtc")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<decimal>("CurrentAtr")
|
||||||
|
.HasColumnType("decimal(18,4)");
|
||||||
|
|
||||||
|
b.Property<decimal>("CurrentPrice")
|
||||||
|
.HasColumnType("decimal(18,4)");
|
||||||
|
|
||||||
|
b.Property<string>("Direction")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(10)
|
||||||
|
.HasColumnType("character varying(10)");
|
||||||
|
|
||||||
|
b.Property<decimal>("EntryPrice")
|
||||||
|
.HasColumnType("decimal(18,4)");
|
||||||
|
|
||||||
|
b.Property<decimal>("EstimatedRiskRewardRatio")
|
||||||
|
.HasColumnType("decimal(8,2)");
|
||||||
|
|
||||||
|
b.Property<string>("ExitPlan")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("jsonb");
|
||||||
|
|
||||||
|
b.Property<DateTime>("ExpiresAtUtc")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("IndicatorSnapshot")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("jsonb");
|
||||||
|
|
||||||
|
b.Property<decimal>("InvalidationPrice")
|
||||||
|
.HasColumnType("decimal(18,4)");
|
||||||
|
|
||||||
|
b.Property<bool>("IsActive")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<bool>("IsTopPick")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<string>("Isin")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(20)
|
||||||
|
.HasColumnType("character varying(20)");
|
||||||
|
|
||||||
|
b.Property<decimal>("QualityScore")
|
||||||
|
.HasColumnType("decimal(6,2)");
|
||||||
|
|
||||||
|
b.Property<string>("Rating")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(5)
|
||||||
|
.HasColumnType("character varying(5)");
|
||||||
|
|
||||||
|
b.Property<string>("StrategyKey")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(50)
|
||||||
|
.HasColumnType("character varying(50)");
|
||||||
|
|
||||||
|
b.Property<string>("StrategyName")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("character varying(100)");
|
||||||
|
|
||||||
|
b.Property<string>("Symbol")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(30)
|
||||||
|
.HasColumnType("character varying(30)");
|
||||||
|
|
||||||
|
b.Property<string>("TechnicalRationale")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("Timeframe")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(10)
|
||||||
|
.HasColumnType("character varying(10)");
|
||||||
|
|
||||||
|
b.Property<string>("TriggeringPatterns")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("jsonb");
|
||||||
|
|
||||||
|
b.HasKey("SetupId");
|
||||||
|
|
||||||
|
b.HasIndex("CreatedAtUtc");
|
||||||
|
|
||||||
|
b.HasIndex("IsTopPick");
|
||||||
|
|
||||||
|
b.HasIndex("QualityScore");
|
||||||
|
|
||||||
|
b.HasIndex("IsActive", "IsTopPick", "QualityScore");
|
||||||
|
|
||||||
|
b.HasIndex("Isin", "IsActive", "ExpiresAtUtc");
|
||||||
|
|
||||||
|
b.ToTable("fta_technical_setups");
|
||||||
|
});
|
||||||
|
#pragma warning restore 612, 618
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace FinlyticTechnicals.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class SyncTechnicalsModelDrift : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_fta_technical_setups_IsActive_IsTopPick_QualityScore",
|
||||||
|
table: "fta_technical_setups",
|
||||||
|
columns: new[] { "IsActive", "IsTopPick", "QualityScore" });
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "IX_fta_technical_setups_IsActive_IsTopPick_QualityScore",
|
||||||
|
table: "fta_technical_setups");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+331
@@ -0,0 +1,331 @@
|
|||||||
|
// <auto-generated />
|
||||||
|
using System;
|
||||||
|
using FinlyticTechnicals.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 FinlyticTechnicals.Migrations
|
||||||
|
{
|
||||||
|
[DbContext(typeof(TechnicalAnalysisDbContext))]
|
||||||
|
[Migration("20260822081353_AddMonitoredUniverseTable")]
|
||||||
|
partial class AddMonitoredUniverseTable
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
#pragma warning disable 612, 618
|
||||||
|
modelBuilder
|
||||||
|
.HasAnnotation("ProductVersion", "10.0.9")
|
||||||
|
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||||
|
|
||||||
|
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||||
|
|
||||||
|
modelBuilder.Entity("FinlyticCore.Entities.Settings.SettingEntity", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("Key")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(150)
|
||||||
|
.HasColumnType("character varying(150)");
|
||||||
|
|
||||||
|
b.Property<DateTime>("LastUpdatedUtc")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("ServiceIdentifier")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("character varying(100)");
|
||||||
|
|
||||||
|
b.Property<string>("ValueJson")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("Key")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("DynamicSettings");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("FinlyticTechnicals.Entities.FtaCandleEntity", b =>
|
||||||
|
{
|
||||||
|
b.Property<long>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("bigint");
|
||||||
|
|
||||||
|
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
||||||
|
|
||||||
|
b.Property<decimal?>("Ask")
|
||||||
|
.HasColumnType("decimal(18,4)");
|
||||||
|
|
||||||
|
b.Property<decimal?>("Bid")
|
||||||
|
.HasColumnType("decimal(18,4)");
|
||||||
|
|
||||||
|
b.Property<decimal>("Close")
|
||||||
|
.HasColumnType("decimal(18,4)");
|
||||||
|
|
||||||
|
b.Property<decimal>("High")
|
||||||
|
.HasColumnType("decimal(18,4)");
|
||||||
|
|
||||||
|
b.Property<string>("Isin")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(20)
|
||||||
|
.HasColumnType("character varying(20)");
|
||||||
|
|
||||||
|
b.Property<decimal>("Low")
|
||||||
|
.HasColumnType("decimal(18,4)");
|
||||||
|
|
||||||
|
b.Property<decimal>("Open")
|
||||||
|
.HasColumnType("decimal(18,4)");
|
||||||
|
|
||||||
|
b.Property<string>("Symbol")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(30)
|
||||||
|
.HasColumnType("character varying(30)");
|
||||||
|
|
||||||
|
b.Property<string>("Timeframe")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(10)
|
||||||
|
.HasColumnType("character varying(10)");
|
||||||
|
|
||||||
|
b.Property<DateTime>("TimestampUtc")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<long>("Volume")
|
||||||
|
.HasColumnType("bigint");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("TimestampUtc");
|
||||||
|
|
||||||
|
b.HasIndex("Isin", "Timeframe", "TimestampUtc");
|
||||||
|
|
||||||
|
b.ToTable("fta_candles");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("FinlyticTechnicals.Entities.FtaDetectedPatternEntity", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("Bias")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(20)
|
||||||
|
.HasColumnType("character varying(20)");
|
||||||
|
|
||||||
|
b.Property<string>("Category")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(30)
|
||||||
|
.HasColumnType("character varying(30)");
|
||||||
|
|
||||||
|
b.Property<string>("Description")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<DateTime>("DetectedAtUtc")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("ExtraData")
|
||||||
|
.HasColumnType("jsonb");
|
||||||
|
|
||||||
|
b.Property<decimal>("InvalidationLevel")
|
||||||
|
.HasColumnType("decimal(18,4)");
|
||||||
|
|
||||||
|
b.Property<string>("Isin")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(20)
|
||||||
|
.HasColumnType("character varying(20)");
|
||||||
|
|
||||||
|
b.Property<decimal>("KeyPriceLevel")
|
||||||
|
.HasColumnType("decimal(18,4)");
|
||||||
|
|
||||||
|
b.Property<decimal>("LowerBoundary")
|
||||||
|
.HasColumnType("decimal(18,4)");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("character varying(100)");
|
||||||
|
|
||||||
|
b.Property<string>("PatternType")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(50)
|
||||||
|
.HasColumnType("character varying(50)");
|
||||||
|
|
||||||
|
b.Property<decimal>("QualityScore")
|
||||||
|
.HasColumnType("decimal(6,2)");
|
||||||
|
|
||||||
|
b.Property<string>("Timeframe")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(10)
|
||||||
|
.HasColumnType("character varying(10)");
|
||||||
|
|
||||||
|
b.Property<decimal>("UpperBoundary")
|
||||||
|
.HasColumnType("decimal(18,4)");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("PatternType");
|
||||||
|
|
||||||
|
b.HasIndex("QualityScore");
|
||||||
|
|
||||||
|
b.HasIndex("Isin", "DetectedAtUtc");
|
||||||
|
|
||||||
|
b.ToTable("fta_detected_patterns");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("FinlyticTechnicals.Entities.FtaMonitoredUniverseAssetEntity", b =>
|
||||||
|
{
|
||||||
|
b.Property<string>("Isin")
|
||||||
|
.HasMaxLength(20)
|
||||||
|
.HasColumnType("character varying(20)");
|
||||||
|
|
||||||
|
b.Property<DateTime>("AddedAtUtc")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("ExpiresAtUtc")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<int>("Priority")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<string>("Source")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(20)
|
||||||
|
.HasColumnType("character varying(20)");
|
||||||
|
|
||||||
|
b.Property<string>("Symbol")
|
||||||
|
.HasMaxLength(30)
|
||||||
|
.HasColumnType("character varying(30)");
|
||||||
|
|
||||||
|
b.HasKey("Isin");
|
||||||
|
|
||||||
|
b.HasIndex("ExpiresAtUtc");
|
||||||
|
|
||||||
|
b.HasIndex("Source");
|
||||||
|
|
||||||
|
b.ToTable("fta_monitored_universe_assets");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("FinlyticTechnicals.Entities.FtaTechnicalSetupEntity", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("SetupId")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAtUtc")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<decimal>("CurrentAtr")
|
||||||
|
.HasColumnType("decimal(18,4)");
|
||||||
|
|
||||||
|
b.Property<decimal>("CurrentPrice")
|
||||||
|
.HasColumnType("decimal(18,4)");
|
||||||
|
|
||||||
|
b.Property<string>("Direction")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(10)
|
||||||
|
.HasColumnType("character varying(10)");
|
||||||
|
|
||||||
|
b.Property<decimal>("EntryPrice")
|
||||||
|
.HasColumnType("decimal(18,4)");
|
||||||
|
|
||||||
|
b.Property<decimal>("EstimatedRiskRewardRatio")
|
||||||
|
.HasColumnType("decimal(8,2)");
|
||||||
|
|
||||||
|
b.Property<string>("ExitPlan")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("jsonb");
|
||||||
|
|
||||||
|
b.Property<DateTime>("ExpiresAtUtc")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("IndicatorSnapshot")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("jsonb");
|
||||||
|
|
||||||
|
b.Property<decimal>("InvalidationPrice")
|
||||||
|
.HasColumnType("decimal(18,4)");
|
||||||
|
|
||||||
|
b.Property<bool>("IsActive")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<bool>("IsTopPick")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<string>("Isin")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(20)
|
||||||
|
.HasColumnType("character varying(20)");
|
||||||
|
|
||||||
|
b.Property<decimal>("QualityScore")
|
||||||
|
.HasColumnType("decimal(6,2)");
|
||||||
|
|
||||||
|
b.Property<string>("Rating")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(5)
|
||||||
|
.HasColumnType("character varying(5)");
|
||||||
|
|
||||||
|
b.Property<string>("StrategyKey")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(50)
|
||||||
|
.HasColumnType("character varying(50)");
|
||||||
|
|
||||||
|
b.Property<string>("StrategyName")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("character varying(100)");
|
||||||
|
|
||||||
|
b.Property<string>("Symbol")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(30)
|
||||||
|
.HasColumnType("character varying(30)");
|
||||||
|
|
||||||
|
b.Property<string>("TechnicalRationale")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("Timeframe")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(10)
|
||||||
|
.HasColumnType("character varying(10)");
|
||||||
|
|
||||||
|
b.Property<string>("TriggeringPatterns")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("jsonb");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("UniverseEnteredAtUtc")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("UniverseSource")
|
||||||
|
.HasMaxLength(20)
|
||||||
|
.HasColumnType("character varying(20)");
|
||||||
|
|
||||||
|
b.HasKey("SetupId");
|
||||||
|
|
||||||
|
b.HasIndex("CreatedAtUtc");
|
||||||
|
|
||||||
|
b.HasIndex("IsTopPick");
|
||||||
|
|
||||||
|
b.HasIndex("QualityScore");
|
||||||
|
|
||||||
|
b.HasIndex("IsActive", "IsTopPick", "QualityScore");
|
||||||
|
|
||||||
|
b.HasIndex("Isin", "IsActive", "ExpiresAtUtc");
|
||||||
|
|
||||||
|
b.ToTable("fta_technical_setups");
|
||||||
|
});
|
||||||
|
#pragma warning restore 612, 618
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace FinlyticTechnicals.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddMonitoredUniverseTable : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<DateTime>(
|
||||||
|
name: "UniverseEnteredAtUtc",
|
||||||
|
table: "fta_technical_setups",
|
||||||
|
type: "timestamp with time zone",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "UniverseSource",
|
||||||
|
table: "fta_technical_setups",
|
||||||
|
type: "character varying(20)",
|
||||||
|
maxLength: 20,
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "fta_monitored_universe_assets",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Isin = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false),
|
||||||
|
Symbol = table.Column<string>(type: "character varying(30)", maxLength: 30, nullable: true),
|
||||||
|
Source = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false),
|
||||||
|
Priority = table.Column<int>(type: "integer", nullable: false),
|
||||||
|
AddedAtUtc = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||||
|
ExpiresAtUtc = table.Column<DateTime>(type: "timestamp with time zone", nullable: true)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_fta_monitored_universe_assets", x => x.Isin);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_fta_monitored_universe_assets_ExpiresAtUtc",
|
||||||
|
table: "fta_monitored_universe_assets",
|
||||||
|
column: "ExpiresAtUtc");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_fta_monitored_universe_assets_Source",
|
||||||
|
table: "fta_monitored_universe_assets",
|
||||||
|
column: "Source");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "fta_monitored_universe_assets");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "UniverseEnteredAtUtc",
|
||||||
|
table: "fta_technical_setups");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "UniverseSource",
|
||||||
|
table: "fta_technical_setups");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+335
@@ -0,0 +1,335 @@
|
|||||||
|
// <auto-generated />
|
||||||
|
using System;
|
||||||
|
using FinlyticTechnicals.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 FinlyticTechnicals.Migrations
|
||||||
|
{
|
||||||
|
[DbContext(typeof(TechnicalAnalysisDbContext))]
|
||||||
|
[Migration("20260822090549_AddRegimeToTechnicalSetups")]
|
||||||
|
partial class AddRegimeToTechnicalSetups
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
#pragma warning disable 612, 618
|
||||||
|
modelBuilder
|
||||||
|
.HasAnnotation("ProductVersion", "10.0.9")
|
||||||
|
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||||
|
|
||||||
|
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||||
|
|
||||||
|
modelBuilder.Entity("FinlyticCore.Entities.Settings.SettingEntity", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("Key")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(150)
|
||||||
|
.HasColumnType("character varying(150)");
|
||||||
|
|
||||||
|
b.Property<DateTime>("LastUpdatedUtc")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("ServiceIdentifier")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("character varying(100)");
|
||||||
|
|
||||||
|
b.Property<string>("ValueJson")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("Key")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("DynamicSettings");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("FinlyticTechnicals.Entities.FtaCandleEntity", b =>
|
||||||
|
{
|
||||||
|
b.Property<long>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("bigint");
|
||||||
|
|
||||||
|
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
||||||
|
|
||||||
|
b.Property<decimal?>("Ask")
|
||||||
|
.HasColumnType("decimal(18,4)");
|
||||||
|
|
||||||
|
b.Property<decimal?>("Bid")
|
||||||
|
.HasColumnType("decimal(18,4)");
|
||||||
|
|
||||||
|
b.Property<decimal>("Close")
|
||||||
|
.HasColumnType("decimal(18,4)");
|
||||||
|
|
||||||
|
b.Property<decimal>("High")
|
||||||
|
.HasColumnType("decimal(18,4)");
|
||||||
|
|
||||||
|
b.Property<string>("Isin")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(20)
|
||||||
|
.HasColumnType("character varying(20)");
|
||||||
|
|
||||||
|
b.Property<decimal>("Low")
|
||||||
|
.HasColumnType("decimal(18,4)");
|
||||||
|
|
||||||
|
b.Property<decimal>("Open")
|
||||||
|
.HasColumnType("decimal(18,4)");
|
||||||
|
|
||||||
|
b.Property<string>("Symbol")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(30)
|
||||||
|
.HasColumnType("character varying(30)");
|
||||||
|
|
||||||
|
b.Property<string>("Timeframe")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(10)
|
||||||
|
.HasColumnType("character varying(10)");
|
||||||
|
|
||||||
|
b.Property<DateTime>("TimestampUtc")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<long>("Volume")
|
||||||
|
.HasColumnType("bigint");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("TimestampUtc");
|
||||||
|
|
||||||
|
b.HasIndex("Isin", "Timeframe", "TimestampUtc");
|
||||||
|
|
||||||
|
b.ToTable("fta_candles");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("FinlyticTechnicals.Entities.FtaDetectedPatternEntity", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("Bias")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(20)
|
||||||
|
.HasColumnType("character varying(20)");
|
||||||
|
|
||||||
|
b.Property<string>("Category")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(30)
|
||||||
|
.HasColumnType("character varying(30)");
|
||||||
|
|
||||||
|
b.Property<string>("Description")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<DateTime>("DetectedAtUtc")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("ExtraData")
|
||||||
|
.HasColumnType("jsonb");
|
||||||
|
|
||||||
|
b.Property<decimal>("InvalidationLevel")
|
||||||
|
.HasColumnType("decimal(18,4)");
|
||||||
|
|
||||||
|
b.Property<string>("Isin")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(20)
|
||||||
|
.HasColumnType("character varying(20)");
|
||||||
|
|
||||||
|
b.Property<decimal>("KeyPriceLevel")
|
||||||
|
.HasColumnType("decimal(18,4)");
|
||||||
|
|
||||||
|
b.Property<decimal>("LowerBoundary")
|
||||||
|
.HasColumnType("decimal(18,4)");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("character varying(100)");
|
||||||
|
|
||||||
|
b.Property<string>("PatternType")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(50)
|
||||||
|
.HasColumnType("character varying(50)");
|
||||||
|
|
||||||
|
b.Property<decimal>("QualityScore")
|
||||||
|
.HasColumnType("decimal(6,2)");
|
||||||
|
|
||||||
|
b.Property<string>("Timeframe")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(10)
|
||||||
|
.HasColumnType("character varying(10)");
|
||||||
|
|
||||||
|
b.Property<decimal>("UpperBoundary")
|
||||||
|
.HasColumnType("decimal(18,4)");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("PatternType");
|
||||||
|
|
||||||
|
b.HasIndex("QualityScore");
|
||||||
|
|
||||||
|
b.HasIndex("Isin", "DetectedAtUtc");
|
||||||
|
|
||||||
|
b.ToTable("fta_detected_patterns");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("FinlyticTechnicals.Entities.FtaMonitoredUniverseAssetEntity", b =>
|
||||||
|
{
|
||||||
|
b.Property<string>("Isin")
|
||||||
|
.HasMaxLength(20)
|
||||||
|
.HasColumnType("character varying(20)");
|
||||||
|
|
||||||
|
b.Property<DateTime>("AddedAtUtc")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("ExpiresAtUtc")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<int>("Priority")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<string>("Source")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(20)
|
||||||
|
.HasColumnType("character varying(20)");
|
||||||
|
|
||||||
|
b.Property<string>("Symbol")
|
||||||
|
.HasMaxLength(30)
|
||||||
|
.HasColumnType("character varying(30)");
|
||||||
|
|
||||||
|
b.HasKey("Isin");
|
||||||
|
|
||||||
|
b.HasIndex("ExpiresAtUtc");
|
||||||
|
|
||||||
|
b.HasIndex("Source");
|
||||||
|
|
||||||
|
b.ToTable("fta_monitored_universe_assets");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("FinlyticTechnicals.Entities.FtaTechnicalSetupEntity", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("SetupId")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAtUtc")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<decimal>("CurrentAtr")
|
||||||
|
.HasColumnType("decimal(18,4)");
|
||||||
|
|
||||||
|
b.Property<decimal>("CurrentPrice")
|
||||||
|
.HasColumnType("decimal(18,4)");
|
||||||
|
|
||||||
|
b.Property<string>("Direction")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(10)
|
||||||
|
.HasColumnType("character varying(10)");
|
||||||
|
|
||||||
|
b.Property<decimal>("EntryPrice")
|
||||||
|
.HasColumnType("decimal(18,4)");
|
||||||
|
|
||||||
|
b.Property<decimal>("EstimatedRiskRewardRatio")
|
||||||
|
.HasColumnType("decimal(8,2)");
|
||||||
|
|
||||||
|
b.Property<string>("ExitPlan")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("jsonb");
|
||||||
|
|
||||||
|
b.Property<DateTime>("ExpiresAtUtc")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("IndicatorSnapshot")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("jsonb");
|
||||||
|
|
||||||
|
b.Property<decimal>("InvalidationPrice")
|
||||||
|
.HasColumnType("decimal(18,4)");
|
||||||
|
|
||||||
|
b.Property<bool>("IsActive")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<bool>("IsTopPick")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<string>("Isin")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(20)
|
||||||
|
.HasColumnType("character varying(20)");
|
||||||
|
|
||||||
|
b.Property<decimal>("QualityScore")
|
||||||
|
.HasColumnType("decimal(6,2)");
|
||||||
|
|
||||||
|
b.Property<string>("Rating")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(5)
|
||||||
|
.HasColumnType("character varying(5)");
|
||||||
|
|
||||||
|
b.Property<string>("Regime")
|
||||||
|
.HasMaxLength(30)
|
||||||
|
.HasColumnType("character varying(30)");
|
||||||
|
|
||||||
|
b.Property<string>("StrategyKey")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(50)
|
||||||
|
.HasColumnType("character varying(50)");
|
||||||
|
|
||||||
|
b.Property<string>("StrategyName")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("character varying(100)");
|
||||||
|
|
||||||
|
b.Property<string>("Symbol")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(30)
|
||||||
|
.HasColumnType("character varying(30)");
|
||||||
|
|
||||||
|
b.Property<string>("TechnicalRationale")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("Timeframe")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(10)
|
||||||
|
.HasColumnType("character varying(10)");
|
||||||
|
|
||||||
|
b.Property<string>("TriggeringPatterns")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("jsonb");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("UniverseEnteredAtUtc")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("UniverseSource")
|
||||||
|
.HasMaxLength(20)
|
||||||
|
.HasColumnType("character varying(20)");
|
||||||
|
|
||||||
|
b.HasKey("SetupId");
|
||||||
|
|
||||||
|
b.HasIndex("CreatedAtUtc");
|
||||||
|
|
||||||
|
b.HasIndex("IsTopPick");
|
||||||
|
|
||||||
|
b.HasIndex("QualityScore");
|
||||||
|
|
||||||
|
b.HasIndex("IsActive", "IsTopPick", "QualityScore");
|
||||||
|
|
||||||
|
b.HasIndex("Isin", "IsActive", "ExpiresAtUtc");
|
||||||
|
|
||||||
|
b.ToTable("fta_technical_setups");
|
||||||
|
});
|
||||||
|
#pragma warning restore 612, 618
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace FinlyticTechnicals.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddRegimeToTechnicalSetups : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "Regime",
|
||||||
|
table: "fta_technical_setups",
|
||||||
|
type: "character varying(30)",
|
||||||
|
maxLength: 30,
|
||||||
|
nullable: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "Regime",
|
||||||
|
table: "fta_technical_setups");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,332 @@
|
|||||||
|
// <auto-generated />
|
||||||
|
using System;
|
||||||
|
using FinlyticTechnicals.Database;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
|
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||||
|
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace FinlyticTechnicals.Migrations
|
||||||
|
{
|
||||||
|
[DbContext(typeof(TechnicalAnalysisDbContext))]
|
||||||
|
partial class TechnicalAnalysisDbContextModelSnapshot : ModelSnapshot
|
||||||
|
{
|
||||||
|
protected override void BuildModel(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
#pragma warning disable 612, 618
|
||||||
|
modelBuilder
|
||||||
|
.HasAnnotation("ProductVersion", "10.0.9")
|
||||||
|
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||||
|
|
||||||
|
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||||
|
|
||||||
|
modelBuilder.Entity("FinlyticCore.Entities.Settings.SettingEntity", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("Key")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(150)
|
||||||
|
.HasColumnType("character varying(150)");
|
||||||
|
|
||||||
|
b.Property<DateTime>("LastUpdatedUtc")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("ServiceIdentifier")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("character varying(100)");
|
||||||
|
|
||||||
|
b.Property<string>("ValueJson")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("Key")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("DynamicSettings");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("FinlyticTechnicals.Entities.FtaCandleEntity", b =>
|
||||||
|
{
|
||||||
|
b.Property<long>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("bigint");
|
||||||
|
|
||||||
|
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
||||||
|
|
||||||
|
b.Property<decimal?>("Ask")
|
||||||
|
.HasColumnType("decimal(18,4)");
|
||||||
|
|
||||||
|
b.Property<decimal?>("Bid")
|
||||||
|
.HasColumnType("decimal(18,4)");
|
||||||
|
|
||||||
|
b.Property<decimal>("Close")
|
||||||
|
.HasColumnType("decimal(18,4)");
|
||||||
|
|
||||||
|
b.Property<decimal>("High")
|
||||||
|
.HasColumnType("decimal(18,4)");
|
||||||
|
|
||||||
|
b.Property<string>("Isin")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(20)
|
||||||
|
.HasColumnType("character varying(20)");
|
||||||
|
|
||||||
|
b.Property<decimal>("Low")
|
||||||
|
.HasColumnType("decimal(18,4)");
|
||||||
|
|
||||||
|
b.Property<decimal>("Open")
|
||||||
|
.HasColumnType("decimal(18,4)");
|
||||||
|
|
||||||
|
b.Property<string>("Symbol")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(30)
|
||||||
|
.HasColumnType("character varying(30)");
|
||||||
|
|
||||||
|
b.Property<string>("Timeframe")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(10)
|
||||||
|
.HasColumnType("character varying(10)");
|
||||||
|
|
||||||
|
b.Property<DateTime>("TimestampUtc")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<long>("Volume")
|
||||||
|
.HasColumnType("bigint");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("TimestampUtc");
|
||||||
|
|
||||||
|
b.HasIndex("Isin", "Timeframe", "TimestampUtc");
|
||||||
|
|
||||||
|
b.ToTable("fta_candles");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("FinlyticTechnicals.Entities.FtaDetectedPatternEntity", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("Bias")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(20)
|
||||||
|
.HasColumnType("character varying(20)");
|
||||||
|
|
||||||
|
b.Property<string>("Category")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(30)
|
||||||
|
.HasColumnType("character varying(30)");
|
||||||
|
|
||||||
|
b.Property<string>("Description")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<DateTime>("DetectedAtUtc")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("ExtraData")
|
||||||
|
.HasColumnType("jsonb");
|
||||||
|
|
||||||
|
b.Property<decimal>("InvalidationLevel")
|
||||||
|
.HasColumnType("decimal(18,4)");
|
||||||
|
|
||||||
|
b.Property<string>("Isin")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(20)
|
||||||
|
.HasColumnType("character varying(20)");
|
||||||
|
|
||||||
|
b.Property<decimal>("KeyPriceLevel")
|
||||||
|
.HasColumnType("decimal(18,4)");
|
||||||
|
|
||||||
|
b.Property<decimal>("LowerBoundary")
|
||||||
|
.HasColumnType("decimal(18,4)");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("character varying(100)");
|
||||||
|
|
||||||
|
b.Property<string>("PatternType")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(50)
|
||||||
|
.HasColumnType("character varying(50)");
|
||||||
|
|
||||||
|
b.Property<decimal>("QualityScore")
|
||||||
|
.HasColumnType("decimal(6,2)");
|
||||||
|
|
||||||
|
b.Property<string>("Timeframe")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(10)
|
||||||
|
.HasColumnType("character varying(10)");
|
||||||
|
|
||||||
|
b.Property<decimal>("UpperBoundary")
|
||||||
|
.HasColumnType("decimal(18,4)");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("PatternType");
|
||||||
|
|
||||||
|
b.HasIndex("QualityScore");
|
||||||
|
|
||||||
|
b.HasIndex("Isin", "DetectedAtUtc");
|
||||||
|
|
||||||
|
b.ToTable("fta_detected_patterns");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("FinlyticTechnicals.Entities.FtaMonitoredUniverseAssetEntity", b =>
|
||||||
|
{
|
||||||
|
b.Property<string>("Isin")
|
||||||
|
.HasMaxLength(20)
|
||||||
|
.HasColumnType("character varying(20)");
|
||||||
|
|
||||||
|
b.Property<DateTime>("AddedAtUtc")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("ExpiresAtUtc")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<int>("Priority")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<string>("Source")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(20)
|
||||||
|
.HasColumnType("character varying(20)");
|
||||||
|
|
||||||
|
b.Property<string>("Symbol")
|
||||||
|
.HasMaxLength(30)
|
||||||
|
.HasColumnType("character varying(30)");
|
||||||
|
|
||||||
|
b.HasKey("Isin");
|
||||||
|
|
||||||
|
b.HasIndex("ExpiresAtUtc");
|
||||||
|
|
||||||
|
b.HasIndex("Source");
|
||||||
|
|
||||||
|
b.ToTable("fta_monitored_universe_assets");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("FinlyticTechnicals.Entities.FtaTechnicalSetupEntity", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("SetupId")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAtUtc")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<decimal>("CurrentAtr")
|
||||||
|
.HasColumnType("decimal(18,4)");
|
||||||
|
|
||||||
|
b.Property<decimal>("CurrentPrice")
|
||||||
|
.HasColumnType("decimal(18,4)");
|
||||||
|
|
||||||
|
b.Property<string>("Direction")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(10)
|
||||||
|
.HasColumnType("character varying(10)");
|
||||||
|
|
||||||
|
b.Property<decimal>("EntryPrice")
|
||||||
|
.HasColumnType("decimal(18,4)");
|
||||||
|
|
||||||
|
b.Property<decimal>("EstimatedRiskRewardRatio")
|
||||||
|
.HasColumnType("decimal(8,2)");
|
||||||
|
|
||||||
|
b.Property<string>("ExitPlan")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("jsonb");
|
||||||
|
|
||||||
|
b.Property<DateTime>("ExpiresAtUtc")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("IndicatorSnapshot")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("jsonb");
|
||||||
|
|
||||||
|
b.Property<decimal>("InvalidationPrice")
|
||||||
|
.HasColumnType("decimal(18,4)");
|
||||||
|
|
||||||
|
b.Property<bool>("IsActive")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<bool>("IsTopPick")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<string>("Isin")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(20)
|
||||||
|
.HasColumnType("character varying(20)");
|
||||||
|
|
||||||
|
b.Property<decimal>("QualityScore")
|
||||||
|
.HasColumnType("decimal(6,2)");
|
||||||
|
|
||||||
|
b.Property<string>("Rating")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(5)
|
||||||
|
.HasColumnType("character varying(5)");
|
||||||
|
|
||||||
|
b.Property<string>("Regime")
|
||||||
|
.HasMaxLength(30)
|
||||||
|
.HasColumnType("character varying(30)");
|
||||||
|
|
||||||
|
b.Property<string>("StrategyKey")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(50)
|
||||||
|
.HasColumnType("character varying(50)");
|
||||||
|
|
||||||
|
b.Property<string>("StrategyName")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("character varying(100)");
|
||||||
|
|
||||||
|
b.Property<string>("Symbol")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(30)
|
||||||
|
.HasColumnType("character varying(30)");
|
||||||
|
|
||||||
|
b.Property<string>("TechnicalRationale")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("Timeframe")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(10)
|
||||||
|
.HasColumnType("character varying(10)");
|
||||||
|
|
||||||
|
b.Property<string>("TriggeringPatterns")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("jsonb");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("UniverseEnteredAtUtc")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("UniverseSource")
|
||||||
|
.HasMaxLength(20)
|
||||||
|
.HasColumnType("character varying(20)");
|
||||||
|
|
||||||
|
b.HasKey("SetupId");
|
||||||
|
|
||||||
|
b.HasIndex("CreatedAtUtc");
|
||||||
|
|
||||||
|
b.HasIndex("IsTopPick");
|
||||||
|
|
||||||
|
b.HasIndex("QualityScore");
|
||||||
|
|
||||||
|
b.HasIndex("IsActive", "IsTopPick", "QualityScore");
|
||||||
|
|
||||||
|
b.HasIndex("Isin", "IsActive", "ExpiresAtUtc");
|
||||||
|
|
||||||
|
b.ToTable("fta_technical_setups");
|
||||||
|
});
|
||||||
|
#pragma warning restore 612, 618
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,250 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using FinlyticCore.Dtos.TechnicalAnalysis;
|
||||||
|
|
||||||
|
namespace FinlyticTechnicals.Patterns.Candlesticks;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Detects Bullish Hammer (long lower wick at support) and Bearish Shooting Star (long upper wick at resistance).
|
||||||
|
/// </summary>
|
||||||
|
public class HammerShootingStarDetector : IPatternDetector
|
||||||
|
{
|
||||||
|
public PatternType HandledType => PatternType.Hammer;
|
||||||
|
public PatternCategory Category => PatternCategory.Candlestick;
|
||||||
|
|
||||||
|
public PatternResultDto? Evaluate(TechnicalContext context)
|
||||||
|
{
|
||||||
|
var candles = context.PrimaryCandles;
|
||||||
|
if (candles.Count < 5) return null;
|
||||||
|
|
||||||
|
var current = candles.Last();
|
||||||
|
var prev = candles[^2];
|
||||||
|
|
||||||
|
decimal body = Math.Abs(current.Close - current.Open);
|
||||||
|
decimal upperShadow = current.High - Math.Max(current.Open, current.Close);
|
||||||
|
decimal lowerShadow = Math.Min(current.Open, current.Close) - current.Low;
|
||||||
|
decimal totalRange = current.High - current.Low;
|
||||||
|
|
||||||
|
if (totalRange <= 0) return null;
|
||||||
|
|
||||||
|
// Hammer: Lower shadow >= 2x body, upper shadow <= 0.2x body, downtrend context
|
||||||
|
if (lowerShadow >= 2.0m * Math.Max(body, 0.01m) && upperShadow <= 0.3m * totalRange && current.Close < prev.Close * 1.02m)
|
||||||
|
{
|
||||||
|
decimal score = Math.Min(95m, 60m + (lowerShadow / totalRange * 40m));
|
||||||
|
return new PatternResultDto(
|
||||||
|
Id: Guid.NewGuid(),
|
||||||
|
Type: PatternType.Hammer,
|
||||||
|
Category: PatternCategory.Candlestick,
|
||||||
|
Bias: PatternBias.Bullish,
|
||||||
|
Name: "Bullish Hammer",
|
||||||
|
Timeframe: context.Timeframe,
|
||||||
|
DetectedAt: current.Timestamp,
|
||||||
|
KeyPriceLevel: current.Low,
|
||||||
|
UpperBoundary: current.High,
|
||||||
|
LowerBoundary: current.Low,
|
||||||
|
InvalidationLevel: current.Low * 0.995m,
|
||||||
|
QualityScore: score,
|
||||||
|
Description: $"Bullish hammer with {lowerShadow / totalRange:P0} rejection lower wick at {current.Low:F2}."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shooting Star: Upper shadow >= 2x body, lower shadow <= 0.2x body, uptrend context
|
||||||
|
if (upperShadow >= 2.0m * Math.Max(body, 0.01m) && lowerShadow <= 0.3m * totalRange && current.Close > prev.Close * 0.98m)
|
||||||
|
{
|
||||||
|
decimal score = Math.Min(95m, 60m + (upperShadow / totalRange * 40m));
|
||||||
|
return new PatternResultDto(
|
||||||
|
Id: Guid.NewGuid(),
|
||||||
|
Type: PatternType.ShootingStar,
|
||||||
|
Category: PatternCategory.Candlestick,
|
||||||
|
Bias: PatternBias.Bearish,
|
||||||
|
Name: "Bearish Shooting Star",
|
||||||
|
Timeframe: context.Timeframe,
|
||||||
|
DetectedAt: current.Timestamp,
|
||||||
|
KeyPriceLevel: current.High,
|
||||||
|
UpperBoundary: current.High,
|
||||||
|
LowerBoundary: current.Low,
|
||||||
|
InvalidationLevel: current.High * 1.005m,
|
||||||
|
QualityScore: score,
|
||||||
|
Description: $"Bearish shooting star with {upperShadow / totalRange:P0} rejection upper wick at {current.High:F2}."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Detects Bullish and Bearish Engulfing candles.
|
||||||
|
/// </summary>
|
||||||
|
public class EngulfingPatternDetector : IPatternDetector
|
||||||
|
{
|
||||||
|
public PatternType HandledType => PatternType.BullishEngulfing;
|
||||||
|
public PatternCategory Category => PatternCategory.Candlestick;
|
||||||
|
|
||||||
|
public PatternResultDto? Evaluate(TechnicalContext context)
|
||||||
|
{
|
||||||
|
var candles = context.PrimaryCandles;
|
||||||
|
if (candles.Count < 3) return null;
|
||||||
|
|
||||||
|
var curr = candles.Last();
|
||||||
|
var prev = candles[^2];
|
||||||
|
|
||||||
|
bool prevBearish = prev.Close < prev.Open;
|
||||||
|
bool currBullish = curr.Close > curr.Open;
|
||||||
|
|
||||||
|
// Bullish Engulfing: previous red, current green completely engulfing previous body
|
||||||
|
if (prevBearish && currBullish && curr.Open <= prev.Close && curr.Close >= prev.Open)
|
||||||
|
{
|
||||||
|
decimal score = Math.Min(90m, 70m + (curr.Volume > prev.Volume ? 15m : 0m));
|
||||||
|
return new PatternResultDto(
|
||||||
|
Id: Guid.NewGuid(),
|
||||||
|
Type: PatternType.BullishEngulfing,
|
||||||
|
Category: PatternCategory.Candlestick,
|
||||||
|
Bias: PatternBias.Bullish,
|
||||||
|
Name: "Bullish Engulfing",
|
||||||
|
Timeframe: context.Timeframe,
|
||||||
|
DetectedAt: curr.Timestamp,
|
||||||
|
KeyPriceLevel: curr.Open,
|
||||||
|
UpperBoundary: curr.High,
|
||||||
|
LowerBoundary: curr.Low,
|
||||||
|
InvalidationLevel: curr.Low * 0.995m,
|
||||||
|
QualityScore: score,
|
||||||
|
Description: $"Bullish engulfing candle covering previous range [{prev.Close:F2} - {prev.Open:F2}]."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool prevBullish = prev.Close > prev.Open;
|
||||||
|
bool currBearish = curr.Close < curr.Open;
|
||||||
|
|
||||||
|
// Bearish Engulfing: previous green, current red completely engulfing previous body
|
||||||
|
if (prevBullish && currBearish && curr.Open >= prev.Close && curr.Close <= prev.Open)
|
||||||
|
{
|
||||||
|
decimal score = Math.Min(90m, 70m + (curr.Volume > prev.Volume ? 15m : 0m));
|
||||||
|
return new PatternResultDto(
|
||||||
|
Id: Guid.NewGuid(),
|
||||||
|
Type: PatternType.BearishEngulfing,
|
||||||
|
Category: PatternCategory.Candlestick,
|
||||||
|
Bias: PatternBias.Bearish,
|
||||||
|
Name: "Bearish Engulfing",
|
||||||
|
Timeframe: context.Timeframe,
|
||||||
|
DetectedAt: curr.Timestamp,
|
||||||
|
KeyPriceLevel: curr.Open,
|
||||||
|
UpperBoundary: curr.High,
|
||||||
|
LowerBoundary: curr.Low,
|
||||||
|
InvalidationLevel: curr.High * 1.005m,
|
||||||
|
QualityScore: score,
|
||||||
|
Description: $"Bearish engulfing candle covering previous range [{prev.Open:F2} - {prev.Close:F2}]."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Detects Morning Star and Evening Star 3-bar reversal patterns.
|
||||||
|
/// </summary>
|
||||||
|
public class MorningEveningStarDetector : IPatternDetector
|
||||||
|
{
|
||||||
|
public PatternType HandledType => PatternType.MorningStar;
|
||||||
|
public PatternCategory Category => PatternCategory.Candlestick;
|
||||||
|
|
||||||
|
public PatternResultDto? Evaluate(TechnicalContext context)
|
||||||
|
{
|
||||||
|
var candles = context.PrimaryCandles;
|
||||||
|
if (candles.Count < 4) return null;
|
||||||
|
|
||||||
|
var c1 = candles[^3];
|
||||||
|
var c2 = candles[^2]; // Star
|
||||||
|
var c3 = candles.Last();
|
||||||
|
|
||||||
|
decimal body1 = Math.Abs(c1.Close - c1.Open);
|
||||||
|
decimal body2 = Math.Abs(c2.Close - c2.Open);
|
||||||
|
decimal body3 = Math.Abs(c3.Close - c3.Open);
|
||||||
|
|
||||||
|
// Morning Star: Large Bearish + Small Star + Strong Bullish closing > 50% into candle 1
|
||||||
|
if (c1.Close < c1.Open && body2 < body1 * 0.4m && c3.Close > c3.Open && c3.Close >= (c1.Open + c1.Close) / 2m)
|
||||||
|
{
|
||||||
|
return new PatternResultDto(
|
||||||
|
Id: Guid.NewGuid(),
|
||||||
|
Type: PatternType.MorningStar,
|
||||||
|
Category: PatternCategory.Candlestick,
|
||||||
|
Bias: PatternBias.Bullish,
|
||||||
|
Name: "Morning Star",
|
||||||
|
Timeframe: context.Timeframe,
|
||||||
|
DetectedAt: c3.Timestamp,
|
||||||
|
KeyPriceLevel: c2.Low,
|
||||||
|
UpperBoundary: c3.High,
|
||||||
|
LowerBoundary: c2.Low,
|
||||||
|
InvalidationLevel: c2.Low * 0.995m,
|
||||||
|
QualityScore: 85m,
|
||||||
|
Description: $"Morning star reversal with low at {c2.Low:F2}."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Evening Star: Large Bullish + Small Star + Strong Bearish closing < 50% into candle 1
|
||||||
|
if (c1.Close > c1.Open && body2 < body1 * 0.4m && c3.Close < c3.Open && c3.Close <= (c1.Open + c1.Close) / 2m)
|
||||||
|
{
|
||||||
|
return new PatternResultDto(
|
||||||
|
Id: Guid.NewGuid(),
|
||||||
|
Type: PatternType.EveningStar,
|
||||||
|
Category: PatternCategory.Candlestick,
|
||||||
|
Bias: PatternBias.Bearish,
|
||||||
|
Name: "Evening Star",
|
||||||
|
Timeframe: context.Timeframe,
|
||||||
|
DetectedAt: c3.Timestamp,
|
||||||
|
KeyPriceLevel: c2.High,
|
||||||
|
UpperBoundary: c2.High,
|
||||||
|
LowerBoundary: c3.Low,
|
||||||
|
InvalidationLevel: c2.High * 1.005m,
|
||||||
|
QualityScore: 85m,
|
||||||
|
Description: $"Evening star reversal with peak at {c2.High:F2}."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Detects Doji indecision candles at key swing points.
|
||||||
|
/// </summary>
|
||||||
|
public class DojiPatternDetector : IPatternDetector
|
||||||
|
{
|
||||||
|
public PatternType HandledType => PatternType.Doji;
|
||||||
|
public PatternCategory Category => PatternCategory.Candlestick;
|
||||||
|
|
||||||
|
public PatternResultDto? Evaluate(TechnicalContext context)
|
||||||
|
{
|
||||||
|
var candles = context.PrimaryCandles;
|
||||||
|
if (candles.Count < 3) return null;
|
||||||
|
|
||||||
|
var curr = candles.Last();
|
||||||
|
decimal body = Math.Abs(curr.Close - curr.Open);
|
||||||
|
decimal totalRange = curr.High - curr.Low;
|
||||||
|
|
||||||
|
if (totalRange <= 0) return null;
|
||||||
|
|
||||||
|
if (body <= totalRange * 0.10m)
|
||||||
|
{
|
||||||
|
return new PatternResultDto(
|
||||||
|
Id: Guid.NewGuid(),
|
||||||
|
Type: PatternType.Doji,
|
||||||
|
Category: PatternCategory.Candlestick,
|
||||||
|
Bias: PatternBias.Neutral,
|
||||||
|
Name: "Doji",
|
||||||
|
Timeframe: context.Timeframe,
|
||||||
|
DetectedAt: curr.Timestamp,
|
||||||
|
KeyPriceLevel: curr.Close,
|
||||||
|
UpperBoundary: curr.High,
|
||||||
|
LowerBoundary: curr.Low,
|
||||||
|
InvalidationLevel: curr.Low,
|
||||||
|
QualityScore: 65m,
|
||||||
|
Description: $"Doji indecision bar with tight body ({body:F2}) and range [{curr.Low:F2} - {curr.High:F2}]."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using FinlyticCore.Dtos.TechnicalAnalysis;
|
||||||
|
|
||||||
|
namespace FinlyticTechnicals.Patterns.ChartPatterns;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Detects Double Bottom (W-reversal) and Double Top (M-reversal) formations.
|
||||||
|
/// </summary>
|
||||||
|
public class DoubleTopBottomDetector : IPatternDetector
|
||||||
|
{
|
||||||
|
public PatternType HandledType => PatternType.DoubleBottom;
|
||||||
|
public PatternCategory Category => PatternCategory.Chart;
|
||||||
|
|
||||||
|
public PatternResultDto? Evaluate(TechnicalContext context)
|
||||||
|
{
|
||||||
|
var candles = context.PrimaryCandles;
|
||||||
|
if (candles.Count < 25) return null;
|
||||||
|
|
||||||
|
// Search for two prominent swing lows within the last 20 candles
|
||||||
|
int n = candles.Count;
|
||||||
|
var recent = candles.TakeLast(25).ToList();
|
||||||
|
|
||||||
|
decimal min1 = decimal.MaxValue;
|
||||||
|
int min1Idx = -1;
|
||||||
|
decimal min2 = decimal.MaxValue;
|
||||||
|
int min2Idx = -1;
|
||||||
|
decimal peakBetween = 0m;
|
||||||
|
|
||||||
|
for (int i = 2; i < recent.Count - 2; i++)
|
||||||
|
{
|
||||||
|
if (recent[i].Low <= recent[i - 1].Low && recent[i].Low <= recent[i - 2].Low &&
|
||||||
|
recent[i].Low <= recent[i + 1].Low && recent[i].Low <= recent[i + 2].Low)
|
||||||
|
{
|
||||||
|
if (min1Idx == -1)
|
||||||
|
{
|
||||||
|
min1 = recent[i].Low;
|
||||||
|
min1Idx = i;
|
||||||
|
}
|
||||||
|
else if (min2Idx == -1 && i > min1Idx + 4)
|
||||||
|
{
|
||||||
|
min2 = recent[i].Low;
|
||||||
|
min2Idx = i;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (min1Idx != -1 && min2Idx != -1)
|
||||||
|
{
|
||||||
|
// Calculate peak between the two lows (neckline)
|
||||||
|
for (int i = min1Idx; i <= min2Idx; i++)
|
||||||
|
{
|
||||||
|
if (recent[i].High > peakBetween) peakBetween = recent[i].High;
|
||||||
|
}
|
||||||
|
|
||||||
|
decimal priceDifference = Math.Abs(min1 - min2) / min1;
|
||||||
|
var current = recent.Last();
|
||||||
|
|
||||||
|
// Double Bottom validation: lows within 1.5% of each other, current price breaking above neckline or holding second bottom
|
||||||
|
if (priceDifference <= 0.015m && current.Close >= min2 && peakBetween > min1 * 1.01m)
|
||||||
|
{
|
||||||
|
decimal target = peakBetween + (peakBetween - Math.Min(min1, min2));
|
||||||
|
return new PatternResultDto(
|
||||||
|
Id: Guid.NewGuid(),
|
||||||
|
Type: PatternType.DoubleBottom,
|
||||||
|
Category: PatternCategory.Chart,
|
||||||
|
Bias: PatternBias.Bullish,
|
||||||
|
Name: "Double Bottom (W-Pattern)",
|
||||||
|
Timeframe: context.Timeframe,
|
||||||
|
DetectedAt: current.Timestamp,
|
||||||
|
KeyPriceLevel: peakBetween,
|
||||||
|
UpperBoundary: target,
|
||||||
|
LowerBoundary: Math.Min(min1, min2),
|
||||||
|
InvalidationLevel: Math.Min(min1, min2) * 0.995m,
|
||||||
|
QualityScore: 82m,
|
||||||
|
Description: $"Double bottom with bottoms at {min1:F2} & {min2:F2}, neckline at {peakBetween:F2}."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Detects Head & Shoulders and Inverse Head & Shoulders reversal formations.
|
||||||
|
/// </summary>
|
||||||
|
public class HeadAndShouldersDetector : IPatternDetector
|
||||||
|
{
|
||||||
|
public PatternType HandledType => PatternType.HeadAndShoulders;
|
||||||
|
public PatternCategory Category => PatternCategory.Chart;
|
||||||
|
|
||||||
|
public PatternResultDto? Evaluate(TechnicalContext context)
|
||||||
|
{
|
||||||
|
var candles = context.PrimaryCandles;
|
||||||
|
if (candles.Count < 30) return null;
|
||||||
|
|
||||||
|
var recent = candles.TakeLast(30).ToList();
|
||||||
|
// Look for Left Shoulder, Head, Right Shoulder
|
||||||
|
// Head must be significantly higher than Left and Right shoulders
|
||||||
|
decimal maxPrice = recent.Max(c => c.High);
|
||||||
|
int headIdx = recent.FindIndex(c => c.High == maxPrice);
|
||||||
|
|
||||||
|
if (headIdx >= 5 && headIdx <= recent.Count - 5)
|
||||||
|
{
|
||||||
|
decimal leftShoulder = recent.Take(headIdx).Max(c => c.High);
|
||||||
|
decimal rightShoulder = recent.Skip(headIdx + 1).Max(c => c.High);
|
||||||
|
|
||||||
|
if (maxPrice > leftShoulder * 1.01m && maxPrice > rightShoulder * 1.01m &&
|
||||||
|
Math.Abs(leftShoulder - rightShoulder) / leftShoulder <= 0.03m)
|
||||||
|
{
|
||||||
|
decimal neckline = recent.Skip(headIdx - 3).Take(6).Min(c => c.Low);
|
||||||
|
var current = recent.Last();
|
||||||
|
|
||||||
|
if (current.Close <= rightShoulder)
|
||||||
|
{
|
||||||
|
return new PatternResultDto(
|
||||||
|
Id: Guid.NewGuid(),
|
||||||
|
Type: PatternType.HeadAndShoulders,
|
||||||
|
Category: PatternCategory.Chart,
|
||||||
|
Bias: PatternBias.Bearish,
|
||||||
|
Name: "Head & Shoulders",
|
||||||
|
Timeframe: context.Timeframe,
|
||||||
|
DetectedAt: current.Timestamp,
|
||||||
|
KeyPriceLevel: neckline,
|
||||||
|
UpperBoundary: maxPrice,
|
||||||
|
LowerBoundary: neckline - (maxPrice - neckline),
|
||||||
|
InvalidationLevel: maxPrice * 1.005m,
|
||||||
|
QualityScore: 85m,
|
||||||
|
Description: $"Bearish Head & Shoulders with Head at {maxPrice:F2}, Shoulders ~{leftShoulder:F2}, Neckline {neckline:F2}."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Detects Ascending and Descending Triangle consolidations.
|
||||||
|
/// </summary>
|
||||||
|
public class TrianglePatternDetector : IPatternDetector
|
||||||
|
{
|
||||||
|
public PatternType HandledType => PatternType.AscendingTriangle;
|
||||||
|
public PatternCategory Category => PatternCategory.Chart;
|
||||||
|
|
||||||
|
public PatternResultDto? Evaluate(TechnicalContext context)
|
||||||
|
{
|
||||||
|
var candles = context.PrimaryCandles;
|
||||||
|
if (candles.Count < 20) return null;
|
||||||
|
|
||||||
|
var recent = candles.TakeLast(20).ToList();
|
||||||
|
decimal highResistance = recent.Take(15).Max(c => c.High);
|
||||||
|
|
||||||
|
// Check if highs are flat (horizontal resistance) while lows are rising (higher lows)
|
||||||
|
decimal low1 = recent.Take(7).Min(c => c.Low);
|
||||||
|
decimal low2 = recent.Skip(7).Take(7).Min(c => c.Low);
|
||||||
|
decimal low3 = recent.Skip(14).Min(c => c.Low);
|
||||||
|
|
||||||
|
if (low3 > low2 && low2 > low1 && Math.Abs(recent.Last().High - highResistance) / highResistance <= 0.01m)
|
||||||
|
{
|
||||||
|
var curr = recent.Last();
|
||||||
|
return new PatternResultDto(
|
||||||
|
Id: Guid.NewGuid(),
|
||||||
|
Type: PatternType.AscendingTriangle,
|
||||||
|
Category: PatternCategory.Chart,
|
||||||
|
Bias: PatternBias.Bullish,
|
||||||
|
Name: "Ascending Triangle",
|
||||||
|
Timeframe: context.Timeframe,
|
||||||
|
DetectedAt: curr.Timestamp,
|
||||||
|
KeyPriceLevel: highResistance,
|
||||||
|
UpperBoundary: highResistance + (highResistance - low1),
|
||||||
|
LowerBoundary: low3,
|
||||||
|
InvalidationLevel: low3 * 0.995m,
|
||||||
|
QualityScore: 80m,
|
||||||
|
Description: $"Ascending triangle with horizontal resistance at {highResistance:F2} and rising lows ({low1:F2} -> {low2:F2} -> {low3:F2})."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
using FinlyticCore.Dtos.TechnicalAnalysis;
|
||||||
|
|
||||||
|
namespace FinlyticTechnicals.Patterns;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Isolated detector contract for a specific candlestick, chart, or SMC pattern.
|
||||||
|
/// </summary>
|
||||||
|
public interface IPatternDetector
|
||||||
|
{
|
||||||
|
PatternType HandledType { get; }
|
||||||
|
PatternCategory Category { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Evaluates the technical context and returns a detected pattern or null if conditions are not met.
|
||||||
|
/// </summary>
|
||||||
|
PatternResultDto? Evaluate(TechnicalContext context);
|
||||||
|
}
|
||||||
@@ -0,0 +1,250 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using FinlyticCore.Dtos.TechnicalAnalysis;
|
||||||
|
|
||||||
|
namespace FinlyticTechnicals.Patterns.SmartMoney;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Detects Bullish and Bearish Fair Value Gaps (FVG) across 3-candle sequences.
|
||||||
|
/// </summary>
|
||||||
|
public class FairValueGapDetector : IPatternDetector
|
||||||
|
{
|
||||||
|
public PatternType HandledType => PatternType.FairValueGapBullish;
|
||||||
|
public PatternCategory Category => PatternCategory.SmartMoney;
|
||||||
|
|
||||||
|
public PatternResultDto? Evaluate(TechnicalContext context)
|
||||||
|
{
|
||||||
|
var candles = context.PrimaryCandles;
|
||||||
|
if (candles.Count < 3) return null;
|
||||||
|
|
||||||
|
var c1 = candles[^3];
|
||||||
|
var c2 = candles[^2]; // Impulse candle
|
||||||
|
var c3 = candles.Last();
|
||||||
|
|
||||||
|
// Bullish FVG: Candle 1 High < Candle 3 Low (Gap between c1.High and c3.Low)
|
||||||
|
if (c3.Low > c1.High && c2.Close > c2.Open)
|
||||||
|
{
|
||||||
|
decimal gapSize = c3.Low - c1.High;
|
||||||
|
decimal midGap = (c3.Low + c1.High) / 2m;
|
||||||
|
|
||||||
|
if (gapSize >= context.CurrentAtr * 0.25m)
|
||||||
|
{
|
||||||
|
return new PatternResultDto(
|
||||||
|
Id: Guid.NewGuid(),
|
||||||
|
Type: PatternType.FairValueGapBullish,
|
||||||
|
Category: PatternCategory.SmartMoney,
|
||||||
|
Bias: PatternBias.Bullish,
|
||||||
|
Name: "Bullish Fair Value Gap (FVG)",
|
||||||
|
Timeframe: context.Timeframe,
|
||||||
|
DetectedAt: c3.Timestamp,
|
||||||
|
KeyPriceLevel: midGap,
|
||||||
|
UpperBoundary: c3.Low,
|
||||||
|
LowerBoundary: c1.High,
|
||||||
|
InvalidationLevel: c1.High * 0.995m,
|
||||||
|
QualityScore: 88m,
|
||||||
|
Description: $"Bullish FVG imbalance zone [{c1.High:F2} - {c3.Low:F2}] with midpoint at {midGap:F2}."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bearish FVG: Candle 1 Low > Candle 3 High (Gap between c3.High and c1.Low)
|
||||||
|
if (c3.High < c1.Low && c2.Close < c2.Open)
|
||||||
|
{
|
||||||
|
decimal gapSize = c1.Low - c3.High;
|
||||||
|
decimal midGap = (c1.Low + c3.High) / 2m;
|
||||||
|
|
||||||
|
if (gapSize >= context.CurrentAtr * 0.25m)
|
||||||
|
{
|
||||||
|
return new PatternResultDto(
|
||||||
|
Id: Guid.NewGuid(),
|
||||||
|
Type: PatternType.FairValueGapBearish,
|
||||||
|
Category: PatternCategory.SmartMoney,
|
||||||
|
Bias: PatternBias.Bearish,
|
||||||
|
Name: "Bearish Fair Value Gap (FVG)",
|
||||||
|
Timeframe: context.Timeframe,
|
||||||
|
DetectedAt: c3.Timestamp,
|
||||||
|
KeyPriceLevel: midGap,
|
||||||
|
UpperBoundary: c1.Low,
|
||||||
|
LowerBoundary: c3.High,
|
||||||
|
InvalidationLevel: c1.Low * 1.005m,
|
||||||
|
QualityScore: 88m,
|
||||||
|
Description: $"Bearish FVG imbalance zone [{c3.High:F2} - {c1.Low:F2}] with midpoint at {midGap:F2}."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Detects Liquidity Sweeps where price takes out multi-period swing highs/lows and immediately rejects back inside the range.
|
||||||
|
/// </summary>
|
||||||
|
public class LiquiditySweepDetector : IPatternDetector
|
||||||
|
{
|
||||||
|
public PatternType HandledType => PatternType.LiquiditySweepLow;
|
||||||
|
public PatternCategory Category => PatternCategory.SmartMoney;
|
||||||
|
|
||||||
|
public PatternResultDto? Evaluate(TechnicalContext context)
|
||||||
|
{
|
||||||
|
var candles = context.PrimaryCandles;
|
||||||
|
if (candles.Count < 20) return null;
|
||||||
|
|
||||||
|
var lookback = candles.Take(candles.Count - 1).TakeLast(20).ToList();
|
||||||
|
var current = candles.Last();
|
||||||
|
|
||||||
|
decimal swingLow = lookback.Min(c => c.Low);
|
||||||
|
decimal swingHigh = lookback.Max(c => c.High);
|
||||||
|
|
||||||
|
// Bullish Liquidity Sweep (Sweep Low): Pierced previous swing low but closed back above it
|
||||||
|
if (current.Low < swingLow && current.Close > swingLow)
|
||||||
|
{
|
||||||
|
return new PatternResultDto(
|
||||||
|
Id: Guid.NewGuid(),
|
||||||
|
Type: PatternType.LiquiditySweepLow,
|
||||||
|
Category: PatternCategory.SmartMoney,
|
||||||
|
Bias: PatternBias.Bullish,
|
||||||
|
Name: "Bullish Liquidity Sweep (Stop Hunt)",
|
||||||
|
Timeframe: context.Timeframe,
|
||||||
|
DetectedAt: current.Timestamp,
|
||||||
|
KeyPriceLevel: swingLow,
|
||||||
|
UpperBoundary: swingHigh,
|
||||||
|
LowerBoundary: current.Low,
|
||||||
|
InvalidationLevel: current.Low * 0.995m,
|
||||||
|
QualityScore: 92m,
|
||||||
|
Description: $"Bullish liquidity sweep below swing low {swingLow:F2} with wick rejection to {current.Low:F2}."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bearish Liquidity Sweep (Sweep High): Pierced previous swing high but closed back below it
|
||||||
|
if (current.High > swingHigh && current.Close < swingHigh)
|
||||||
|
{
|
||||||
|
return new PatternResultDto(
|
||||||
|
Id: Guid.NewGuid(),
|
||||||
|
Type: PatternType.LiquiditySweepHigh,
|
||||||
|
Category: PatternCategory.SmartMoney,
|
||||||
|
Bias: PatternBias.Bearish,
|
||||||
|
Name: "Bearish Liquidity Sweep (Buy-Side Sweep)",
|
||||||
|
Timeframe: context.Timeframe,
|
||||||
|
DetectedAt: current.Timestamp,
|
||||||
|
KeyPriceLevel: swingHigh,
|
||||||
|
UpperBoundary: current.High,
|
||||||
|
LowerBoundary: swingLow,
|
||||||
|
InvalidationLevel: current.High * 1.005m,
|
||||||
|
QualityScore: 92m,
|
||||||
|
Description: $"Bearish liquidity sweep above swing high {swingHigh:F2} with wick rejection to {current.High:F2}."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Detects Change of Character (CHoCH) structural trend reversals and Break of Structure (BOS) continuations.
|
||||||
|
/// </summary>
|
||||||
|
public class ChochBosDetector : IPatternDetector
|
||||||
|
{
|
||||||
|
public PatternType HandledType => PatternType.ChangeOfCharacter;
|
||||||
|
public PatternCategory Category => PatternCategory.SmartMoney;
|
||||||
|
|
||||||
|
public PatternResultDto? Evaluate(TechnicalContext context)
|
||||||
|
{
|
||||||
|
var candles = context.PrimaryCandles;
|
||||||
|
if (candles.Count < 20) return null;
|
||||||
|
|
||||||
|
var current = candles.Last();
|
||||||
|
var prevCandles = candles.Take(candles.Count - 1).TakeLast(15).ToList();
|
||||||
|
|
||||||
|
decimal priorSwingHigh = prevCandles.Max(c => c.High);
|
||||||
|
decimal priorSwingLow = prevCandles.Min(c => c.Low);
|
||||||
|
|
||||||
|
// Bullish CHoCH: Clean candle body close above previous major swing high
|
||||||
|
if (current.Close > priorSwingHigh && current.Open < priorSwingHigh)
|
||||||
|
{
|
||||||
|
return new PatternResultDto(
|
||||||
|
Id: Guid.NewGuid(),
|
||||||
|
Type: PatternType.ChangeOfCharacter,
|
||||||
|
Category: PatternCategory.SmartMoney,
|
||||||
|
Bias: PatternBias.Bullish,
|
||||||
|
Name: "Bullish Change of Character (CHoCH)",
|
||||||
|
Timeframe: context.Timeframe,
|
||||||
|
DetectedAt: current.Timestamp,
|
||||||
|
KeyPriceLevel: priorSwingHigh,
|
||||||
|
UpperBoundary: current.Close + context.CurrentAtr * 2m,
|
||||||
|
LowerBoundary: priorSwingLow,
|
||||||
|
InvalidationLevel: priorSwingLow,
|
||||||
|
QualityScore: 90m,
|
||||||
|
Description: $"Bullish structural break closing above swing high {priorSwingHigh:F2}."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bearish CHoCH: Clean candle body close below previous major swing low
|
||||||
|
if (current.Close < priorSwingLow && current.Open > priorSwingLow)
|
||||||
|
{
|
||||||
|
return new PatternResultDto(
|
||||||
|
Id: Guid.NewGuid(),
|
||||||
|
Type: PatternType.ChangeOfCharacter,
|
||||||
|
Category: PatternCategory.SmartMoney,
|
||||||
|
Bias: PatternBias.Bearish,
|
||||||
|
Name: "Bearish Change of Character (CHoCH)",
|
||||||
|
Timeframe: context.Timeframe,
|
||||||
|
DetectedAt: current.Timestamp,
|
||||||
|
KeyPriceLevel: priorSwingLow,
|
||||||
|
UpperBoundary: priorSwingHigh,
|
||||||
|
LowerBoundary: current.Close - context.CurrentAtr * 2m,
|
||||||
|
InvalidationLevel: priorSwingHigh,
|
||||||
|
QualityScore: 90m,
|
||||||
|
Description: $"Bearish structural break closing below swing low {priorSwingLow:F2}."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Detects institutional Order Blocks (last opposing candle before a strong directional displacement).
|
||||||
|
/// </summary>
|
||||||
|
public class OrderBlockDetector : IPatternDetector
|
||||||
|
{
|
||||||
|
public PatternType HandledType => PatternType.OrderBlock;
|
||||||
|
public PatternCategory Category => PatternCategory.SmartMoney;
|
||||||
|
|
||||||
|
public PatternResultDto? Evaluate(TechnicalContext context)
|
||||||
|
{
|
||||||
|
var candles = context.PrimaryCandles;
|
||||||
|
if (candles.Count < 5) return null;
|
||||||
|
|
||||||
|
var obCandle = candles[^3];
|
||||||
|
var impulse1 = candles[^2];
|
||||||
|
var impulse2 = candles.Last();
|
||||||
|
|
||||||
|
// Bullish Order Block: Red candle followed by 2 strong green candles that expand price > 1.5 ATR
|
||||||
|
if (obCandle.Close < obCandle.Open && impulse1.Close > impulse1.Open && impulse2.Close > impulse2.Open)
|
||||||
|
{
|
||||||
|
decimal displacement = impulse2.Close - obCandle.Low;
|
||||||
|
if (displacement >= context.CurrentAtr * 1.5m)
|
||||||
|
{
|
||||||
|
return new PatternResultDto(
|
||||||
|
Id: Guid.NewGuid(),
|
||||||
|
Type: PatternType.OrderBlock,
|
||||||
|
Category: PatternCategory.SmartMoney,
|
||||||
|
Bias: PatternBias.Bullish,
|
||||||
|
Name: "Bullish Institutional Order Block",
|
||||||
|
Timeframe: context.Timeframe,
|
||||||
|
DetectedAt: impulse2.Timestamp,
|
||||||
|
KeyPriceLevel: (obCandle.Open + obCandle.Close) / 2m,
|
||||||
|
UpperBoundary: obCandle.High,
|
||||||
|
LowerBoundary: obCandle.Low,
|
||||||
|
InvalidationLevel: obCandle.Low * 0.995m,
|
||||||
|
QualityScore: 86m,
|
||||||
|
Description: $"Bullish order block zone [{obCandle.Low:F2} - {obCandle.High:F2}] with strong displacement."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
using System;
|
||||||
|
using System.Net.Http;
|
||||||
|
using FinlyticCore.Database;
|
||||||
|
using FinlyticCore.Services;
|
||||||
|
using FinlyticCore.Services.TradeRepublic;
|
||||||
|
using FinlyticCore.Services.Yahoo;
|
||||||
|
using FinlyticTechnicals.Database;
|
||||||
|
using FinlyticTechnicals.Patterns;
|
||||||
|
using FinlyticTechnicals.Patterns.Candlesticks;
|
||||||
|
using FinlyticTechnicals.Patterns.ChartPatterns;
|
||||||
|
using FinlyticTechnicals.Patterns.SmartMoney;
|
||||||
|
using FinlyticTechnicals.Services;
|
||||||
|
using FinlyticTechnicals.Strategies;
|
||||||
|
using FinlyticTechnicals.Util;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.Hosting;
|
||||||
|
|
||||||
|
var builder = Host.CreateApplicationBuilder(args);
|
||||||
|
|
||||||
|
// 1. Register DbContext
|
||||||
|
builder.Services.AddDbContext<TechnicalAnalysisDbContext>(options =>
|
||||||
|
options.UseNpgsql(builder.Configuration.GetConnectionString("DefaultConnection")));
|
||||||
|
builder.Services.AddScoped<ISettingsDbContext>(sp => sp.GetRequiredService<TechnicalAnalysisDbContext>());
|
||||||
|
|
||||||
|
// 2. Register Core Services & Logger
|
||||||
|
builder.Services.AddSingleton<ISettingsService, SettingsService>();
|
||||||
|
builder.Services.AddSingleton(typeof(IFinlyticLogger<>), typeof(FinlyticLogger<>));
|
||||||
|
|
||||||
|
// 3. Register HTTP & Market Data Clients
|
||||||
|
builder.Services.AddHttpClient<IYahooMarketDataScraper, YahooMarketDataScraper>()
|
||||||
|
.ConfigurePrimaryHttpMessageHandler(() => new HttpClientHandler
|
||||||
|
{
|
||||||
|
UseCookies = true,
|
||||||
|
CookieContainer = new System.Net.CookieContainer()
|
||||||
|
});
|
||||||
|
builder.Services.AddSingleton<YahooFinanceClient>();
|
||||||
|
builder.Services.AddTransient<IYahooMarketDataScraper, YahooMarketDataScraper>();
|
||||||
|
|
||||||
|
// 4. Register Trade Republic Ingestion & Real-Time Services
|
||||||
|
builder.Services.AddSingleton<TradeRepublicClient>();
|
||||||
|
builder.Services.AddSingleton<ITradeRepublicService, TradeRepublicService>();
|
||||||
|
builder.Services.AddSingleton<ITradeRepublicIngestionService, TradeRepublicIngestionService>();
|
||||||
|
builder.Services.AddSingleton<IMultiTimeframeCandleAggregator, MultiTimeframeCandleAggregator>();
|
||||||
|
|
||||||
|
// 5. Register Pattern Detectors
|
||||||
|
builder.Services.AddSingleton<IPatternDetector, HammerShootingStarDetector>();
|
||||||
|
builder.Services.AddSingleton<IPatternDetector, EngulfingPatternDetector>();
|
||||||
|
builder.Services.AddSingleton<IPatternDetector, MorningEveningStarDetector>();
|
||||||
|
builder.Services.AddSingleton<IPatternDetector, DojiPatternDetector>();
|
||||||
|
builder.Services.AddSingleton<IPatternDetector, DoubleTopBottomDetector>();
|
||||||
|
builder.Services.AddSingleton<IPatternDetector, HeadAndShouldersDetector>();
|
||||||
|
builder.Services.AddSingleton<IPatternDetector, TrianglePatternDetector>();
|
||||||
|
builder.Services.AddSingleton<IPatternDetector, FairValueGapDetector>();
|
||||||
|
builder.Services.AddSingleton<IPatternDetector, LiquiditySweepDetector>();
|
||||||
|
builder.Services.AddSingleton<IPatternDetector, ChochBosDetector>();
|
||||||
|
builder.Services.AddSingleton<IPatternDetector, OrderBlockDetector>();
|
||||||
|
|
||||||
|
// 6. Register Strategies
|
||||||
|
builder.Services.AddSingleton<ITechnicalStrategy, TrendPullbackFvgStrategy>();
|
||||||
|
builder.Services.AddSingleton<ITechnicalStrategy, VolatilitySqueezeStrategy>();
|
||||||
|
builder.Services.AddSingleton<ITechnicalStrategy, SmcLiquiditySweepStrategy>();
|
||||||
|
builder.Services.AddSingleton<ITechnicalStrategy, MeanReversionStrategy>();
|
||||||
|
builder.Services.AddSingleton<ITechnicalStrategy, SuperTrendMultiTfStrategy>();
|
||||||
|
builder.Services.AddSingleton<ITechnicalStrategy, MacdCrossoverStrategy>();
|
||||||
|
builder.Services.AddSingleton<ITechnicalStrategy, MovingAverageCrossoverStrategy>();
|
||||||
|
builder.Services.AddSingleton<ITechnicalStrategy, RsiReversalStrategy>();
|
||||||
|
builder.Services.AddSingleton<ITechnicalStrategy, DonchianBreakoutStrategy>();
|
||||||
|
builder.Services.AddSingleton<ITechnicalStrategy, VwapBounceStrategy>();
|
||||||
|
|
||||||
|
// 7. Register Technical Scoring Engine & Universe Manager
|
||||||
|
builder.Services.AddSingleton<ITechnicalScoringEngine, TechnicalScoringEngine>();
|
||||||
|
builder.Services.AddSingleton<ITechnicalUniverseManager, TechnicalUniverseManager>();
|
||||||
|
|
||||||
|
// 8. Register MQTT Client & RPC Bridge
|
||||||
|
builder.Services.AddSingleton<TAMqttClient>();
|
||||||
|
builder.Services.AddSingleton<ITAMqttRpcClient>(sp => sp.GetRequiredService<TAMqttClient>());
|
||||||
|
builder.Services.AddHostedService(sp => sp.GetRequiredService<TAMqttClient>());
|
||||||
|
|
||||||
|
// 9. Register Technical Scanner Background Service
|
||||||
|
builder.Services.AddHostedService<TechnicalScannerBackgroundService>();
|
||||||
|
|
||||||
|
|
||||||
|
var host = builder.Build();
|
||||||
|
|
||||||
|
// Run startup database migrations
|
||||||
|
using (var scope = host.Services.CreateScope())
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var context = scope.ServiceProvider.GetRequiredService<TechnicalAnalysisDbContext>();
|
||||||
|
var connStr = builder.Configuration.GetConnectionString("DefaultConnection") ?? "";
|
||||||
|
await context.MigrateWithBootstrapAsync(connStr);
|
||||||
|
Console.WriteLine("Database migrations successfully executed for FinlyticTechnicals.");
|
||||||
|
|
||||||
|
// The monitored-universe table backs TechnicalUniverseManager but is deliberately NOT meant to survive
|
||||||
|
// a restart: it is fully rebuilt within minutes from RefreshFavoritesAsync/RefreshDiscoveryAsync and
|
||||||
|
// fresh sentiment-spike events, and a stale row whose TTL never got swept because the process was down
|
||||||
|
// is worse than starting from an empty universe.
|
||||||
|
await context.MonitoredUniverseAssets.ExecuteDeleteAsync();
|
||||||
|
Console.WriteLine("Monitored universe table cleared for fresh start.");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Console.WriteLine($"Migration error on startup: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await host.RunAsync();
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
using System;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
|
namespace FinlyticTechnicals.Services;
|
||||||
|
|
||||||
|
public interface ITAMqttRpcClient
|
||||||
|
{
|
||||||
|
Task<TResponse?> SendRpcRequestAsync<TResponse, TRequest>(
|
||||||
|
string channel,
|
||||||
|
TRequest requestData,
|
||||||
|
TimeSpan? timeout = null)
|
||||||
|
where TResponse : class
|
||||||
|
where TRequest : class;
|
||||||
|
|
||||||
|
Task PublishAsync<T>(string topic, T data, bool retain = false);
|
||||||
|
}
|
||||||
@@ -0,0 +1,245 @@
|
|||||||
|
using System.Collections.Concurrent;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using FinlyticCore.Dtos.TechnicalAnalysis;
|
||||||
|
using FinlyticCore.Services;
|
||||||
|
using FinlyticTechnicals.Timeframe;
|
||||||
|
using FinlyticTechnicals.Util;
|
||||||
|
|
||||||
|
namespace FinlyticTechnicals.Services;
|
||||||
|
|
||||||
|
public interface IMultiTimeframeCandleAggregator
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes historical ring buffers for an ISIN with Yahoo/database candles.
|
||||||
|
/// </summary>
|
||||||
|
void InitializeHistory(string isin, string timeframe, IEnumerable<CandleDto> candles);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Processes an incoming clean tick and updates 1m, 5m, 15m, 1h, and 1d candles.
|
||||||
|
/// </summary>
|
||||||
|
void ProcessTick(CleanLiveTick tick);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets a snapshot of the ring buffer for an ISIN and timeframe.
|
||||||
|
/// </summary>
|
||||||
|
IReadOnlyList<CandleDto> GetCandles(string isin, string timeframe);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets all multi-timeframe candles (1m, 5m, 15m, 1h, 1d) as a dictionary.
|
||||||
|
/// </summary>
|
||||||
|
Dictionary<string, IReadOnlyList<CandleDto>> GetAllTimeframes(string isin);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Event triggered when a timeframe bar completes.
|
||||||
|
/// </summary>
|
||||||
|
event Action<string, string, CandleDto>? OnCandleClosed;
|
||||||
|
}
|
||||||
|
|
||||||
|
public class MultiTimeframeCandleAggregator : IMultiTimeframeCandleAggregator
|
||||||
|
{
|
||||||
|
private readonly IFinlyticLogger<MultiTimeframeCandleAggregator> _logger;
|
||||||
|
private readonly ConcurrentDictionary<string, ConcurrentDictionary<string, CircularRingBuffer<CandleDto>>> _buffers = new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
private readonly ConcurrentDictionary<string, CandleDto> _current1mCandles = new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
private readonly object _aggregationLock = new();
|
||||||
|
|
||||||
|
public event Action<string, string, CandleDto>? OnCandleClosed;
|
||||||
|
|
||||||
|
public MultiTimeframeCandleAggregator(IFinlyticLogger<MultiTimeframeCandleAggregator> logger)
|
||||||
|
{
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void InitializeHistory(string isin, string timeframe, IEnumerable<CandleDto> candles)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(isin) || string.IsNullOrWhiteSpace(timeframe)) return;
|
||||||
|
|
||||||
|
var cleanIsin = isin.Trim().ToUpperInvariant();
|
||||||
|
var cleanTf = timeframe.Trim().ToLowerInvariant();
|
||||||
|
|
||||||
|
var isinBuffers = _buffers.GetOrAdd(cleanIsin, _ => new ConcurrentDictionary<string, CircularRingBuffer<CandleDto>>(StringComparer.OrdinalIgnoreCase));
|
||||||
|
var ringBuffer = isinBuffers.GetOrAdd(cleanTf, _ => new CircularRingBuffer<CandleDto>(500));
|
||||||
|
|
||||||
|
var ordered = candles
|
||||||
|
.Where(c => c.Close > 0m)
|
||||||
|
.OrderBy(c => c.Timestamp)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
ringBuffer.LoadBulk(ordered);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void ProcessTick(CleanLiveTick tick)
|
||||||
|
{
|
||||||
|
if (tick == null || string.IsNullOrWhiteSpace(tick.Isin)) return;
|
||||||
|
|
||||||
|
var isin = tick.Isin.Trim().ToUpperInvariant();
|
||||||
|
var tickTime = tick.TimestampUtc;
|
||||||
|
var minuteBoundary = new DateTime(tickTime.Year, tickTime.Month, tickTime.Day, tickTime.Hour, tickTime.Minute, 0, DateTimeKind.Utc);
|
||||||
|
|
||||||
|
lock (_aggregationLock)
|
||||||
|
{
|
||||||
|
var isinBuffers = _buffers.GetOrAdd(isin, _ => new ConcurrentDictionary<string, CircularRingBuffer<CandleDto>>(StringComparer.OrdinalIgnoreCase));
|
||||||
|
var ringBuffer1m = isinBuffers.GetOrAdd("1m", _ => new CircularRingBuffer<CandleDto>(500));
|
||||||
|
|
||||||
|
if (_current1mCandles.TryGetValue(isin, out var current1m))
|
||||||
|
{
|
||||||
|
if (current1m.Timestamp == minuteBoundary)
|
||||||
|
{
|
||||||
|
// Update current open 1m bar
|
||||||
|
var updated = current1m with
|
||||||
|
{
|
||||||
|
High = Math.Max(current1m.High, tick.MidPrice),
|
||||||
|
Low = Math.Min(current1m.Low, tick.MidPrice),
|
||||||
|
Close = tick.MidPrice,
|
||||||
|
Volume = current1m.Volume + 1,
|
||||||
|
Bid = tick.Bid,
|
||||||
|
Ask = tick.Ask
|
||||||
|
};
|
||||||
|
_current1mCandles[isin] = updated;
|
||||||
|
ringBuffer1m.UpdateLast(updated);
|
||||||
|
}
|
||||||
|
else if (minuteBoundary > current1m.Timestamp)
|
||||||
|
{
|
||||||
|
// 1. Close current 1m bar
|
||||||
|
ringBuffer1m.UpdateLast(current1m);
|
||||||
|
OnCandleClosed?.Invoke(isin, "1m", current1m);
|
||||||
|
|
||||||
|
// 2. Reconnect-Lückenbehandlung (Gap Handling)
|
||||||
|
// If multiple minutes passed without ticks (e.g. disconnect), fill gaps flatly with Volume = 0
|
||||||
|
var gapStart = current1m.Timestamp.AddMinutes(1);
|
||||||
|
var lastClose = current1m.Close;
|
||||||
|
while (gapStart < minuteBoundary)
|
||||||
|
{
|
||||||
|
var flatBar = new CandleDto(
|
||||||
|
Timestamp: gapStart,
|
||||||
|
Open: lastClose,
|
||||||
|
High: lastClose,
|
||||||
|
Low: lastClose,
|
||||||
|
Close: lastClose,
|
||||||
|
Volume: 0,
|
||||||
|
Bid: tick.Bid,
|
||||||
|
Ask: tick.Ask
|
||||||
|
);
|
||||||
|
ringBuffer1m.Add(flatBar);
|
||||||
|
OnCandleClosed?.Invoke(isin, "1m", flatBar);
|
||||||
|
gapStart = gapStart.AddMinutes(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Start new 1m bar
|
||||||
|
var new1m = new CandleDto(
|
||||||
|
Timestamp: minuteBoundary,
|
||||||
|
Open: tick.MidPrice,
|
||||||
|
High: tick.MidPrice,
|
||||||
|
Low: tick.MidPrice,
|
||||||
|
Close: tick.MidPrice,
|
||||||
|
Volume: 1,
|
||||||
|
Bid: tick.Bid,
|
||||||
|
Ask: tick.Ask
|
||||||
|
);
|
||||||
|
_current1mCandles[isin] = new1m;
|
||||||
|
ringBuffer1m.Add(new1m);
|
||||||
|
|
||||||
|
// 4. Update higher timeframes (5m, 15m, 1h, 1d)
|
||||||
|
RebuildHigherTimeframes(isin, isinBuffers, ringBuffer1m);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// First tick for this ISIN
|
||||||
|
var new1m = new CandleDto(
|
||||||
|
Timestamp: minuteBoundary,
|
||||||
|
Open: tick.MidPrice,
|
||||||
|
High: tick.MidPrice,
|
||||||
|
Low: tick.MidPrice,
|
||||||
|
Close: tick.MidPrice,
|
||||||
|
Volume: 1,
|
||||||
|
Bid: tick.Bid,
|
||||||
|
Ask: tick.Ask
|
||||||
|
);
|
||||||
|
_current1mCandles[isin] = new1m;
|
||||||
|
ringBuffer1m.Add(new1m);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RebuildHigherTimeframes(string isin, ConcurrentDictionary<string, CircularRingBuffer<CandleDto>> isinBuffers, CircularRingBuffer<CandleDto> ringBuffer1m)
|
||||||
|
{
|
||||||
|
var snapshot1m = ringBuffer1m.ToArray();
|
||||||
|
if (snapshot1m.Length == 0) return;
|
||||||
|
|
||||||
|
// Build 5m candles
|
||||||
|
AggregatePeriod(isin, isinBuffers, snapshot1m, "5m", 5);
|
||||||
|
|
||||||
|
// Build 15m candles
|
||||||
|
AggregatePeriod(isin, isinBuffers, snapshot1m, "15m", 15);
|
||||||
|
|
||||||
|
// Build 1h candles
|
||||||
|
AggregatePeriod(isin, isinBuffers, snapshot1m, "1h", 60);
|
||||||
|
|
||||||
|
// Build 1d candles
|
||||||
|
AggregateDaily(isin, isinBuffers, snapshot1m);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void AggregatePeriod(string isin, ConcurrentDictionary<string, CircularRingBuffer<CandleDto>> isinBuffers, CandleDto[] candles1m, string tfName, int minutes)
|
||||||
|
{
|
||||||
|
var targetBuffer = isinBuffers.GetOrAdd(tfName, _ => new CircularRingBuffer<CandleDto>(500));
|
||||||
|
targetBuffer.LoadBulk(FinlyticTechnicals.Indicators.CandleResampler.Resample(candles1m, minutes));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void AggregateDaily(string isin, ConcurrentDictionary<string, CircularRingBuffer<CandleDto>> isinBuffers, CandleDto[] candles1m)
|
||||||
|
{
|
||||||
|
var targetBuffer = isinBuffers.GetOrAdd("1d", _ => new CircularRingBuffer<CandleDto>(500));
|
||||||
|
var aggregated = FinlyticTechnicals.Indicators.CandleResampler.Resample(candles1m, 1440);
|
||||||
|
|
||||||
|
// If daily buffer already has deep Yahoo history, stitch today's aggregated bar onto the end
|
||||||
|
if (targetBuffer.Count > 0 && aggregated.Count > 0)
|
||||||
|
{
|
||||||
|
var today = aggregated.Last();
|
||||||
|
var lastHistory = targetBuffer.GetLast();
|
||||||
|
if (lastHistory != null && lastHistory.Timestamp.Date == today.Timestamp.Date)
|
||||||
|
{
|
||||||
|
targetBuffer.UpdateLast(today);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
targetBuffer.Add(today);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (aggregated.Count > 0)
|
||||||
|
{
|
||||||
|
targetBuffer.LoadBulk(aggregated);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public IReadOnlyList<CandleDto> GetCandles(string isin, string timeframe)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(isin)) return [];
|
||||||
|
var cleanIsin = isin.Trim().ToUpperInvariant();
|
||||||
|
var cleanTf = (timeframe ?? "15m").Trim().ToLowerInvariant();
|
||||||
|
|
||||||
|
if (_buffers.TryGetValue(cleanIsin, out var isinBuffers) &&
|
||||||
|
isinBuffers.TryGetValue(cleanTf, out var ringBuffer))
|
||||||
|
{
|
||||||
|
return ringBuffer.ToArray();
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public Dictionary<string, IReadOnlyList<CandleDto>> GetAllTimeframes(string isin)
|
||||||
|
{
|
||||||
|
var result = new Dictionary<string, IReadOnlyList<CandleDto>>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
if (string.IsNullOrWhiteSpace(isin)) return result;
|
||||||
|
|
||||||
|
var cleanIsin = isin.Trim().ToUpperInvariant();
|
||||||
|
if (_buffers.TryGetValue(cleanIsin, out var isinBuffers))
|
||||||
|
{
|
||||||
|
foreach (var kvp in isinBuffers)
|
||||||
|
{
|
||||||
|
result[kvp.Key] = kvp.Value.ToArray();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
using System;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using FinlyticCore.Services;
|
||||||
|
using FinlyticTechnicals.Util;
|
||||||
|
using Microsoft.Extensions.Hosting;
|
||||||
|
|
||||||
|
namespace FinlyticTechnicals.Services;
|
||||||
|
|
||||||
|
public class TechnicalScannerBackgroundService : BackgroundService
|
||||||
|
{
|
||||||
|
private readonly ITechnicalUniverseManager _universeManager;
|
||||||
|
private readonly ITechnicalScoringEngine _scoringEngine;
|
||||||
|
private readonly ISettingsService _settingsService;
|
||||||
|
private readonly IFinlyticLogger<TechnicalScannerBackgroundService> _logger;
|
||||||
|
|
||||||
|
public TechnicalScannerBackgroundService(
|
||||||
|
ITechnicalUniverseManager universeManager,
|
||||||
|
ITechnicalScoringEngine scoringEngine,
|
||||||
|
ISettingsService settingsService,
|
||||||
|
IFinlyticLogger<TechnicalScannerBackgroundService> logger)
|
||||||
|
{
|
||||||
|
_universeManager = universeManager;
|
||||||
|
_scoringEngine = scoringEngine;
|
||||||
|
_settingsService = settingsService;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||||
|
{
|
||||||
|
await _logger.LogInfoAsync(SettingKeys.TechnicalAnalysisChannel,
|
||||||
|
"[TechnicalScanner] Starting Technical Universe Scanner Background Service...");
|
||||||
|
|
||||||
|
// Initial delay for MQTT connections to stabilize
|
||||||
|
await Task.Delay(TimeSpan.FromSeconds(10), stoppingToken);
|
||||||
|
|
||||||
|
// Initial synchronization of Universe
|
||||||
|
await _universeManager.RefreshFavoritesAsync(stoppingToken);
|
||||||
|
await _universeManager.RefreshDiscoveryAsync(stoppingToken);
|
||||||
|
|
||||||
|
DateTime lastFavoritesSyncUtc = DateTime.UtcNow;
|
||||||
|
DateTime lastDiscoverySyncUtc = DateTime.UtcNow;
|
||||||
|
|
||||||
|
while (!stoppingToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
DateTime now = DateTime.UtcNow;
|
||||||
|
|
||||||
|
// 1. Check Periodic Sync Timers
|
||||||
|
if (now - lastFavoritesSyncUtc >= TimeSpan.FromMinutes(15))
|
||||||
|
{
|
||||||
|
await _universeManager.RefreshFavoritesAsync(stoppingToken);
|
||||||
|
lastFavoritesSyncUtc = DateTime.UtcNow;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (now - lastDiscoverySyncUtc >= TimeSpan.FromMinutes(30))
|
||||||
|
{
|
||||||
|
await _universeManager.RefreshDiscoveryAsync(stoppingToken);
|
||||||
|
lastDiscoverySyncUtc = DateTime.UtcNow;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Retrieve Active Prioritized Scan Universe
|
||||||
|
var universe = await _universeManager.GetActiveUniverseAsync(stoppingToken);
|
||||||
|
if (universe.Count > 0)
|
||||||
|
{
|
||||||
|
await _logger.LogInfoAsync(SettingKeys.TechnicalAnalysisChannel,
|
||||||
|
"[TechnicalScanner] Scanning {Count} assets in active universe across all strategies...", universe.Count);
|
||||||
|
|
||||||
|
foreach (var entry in universe)
|
||||||
|
{
|
||||||
|
if (stoppingToken.IsCancellationRequested) break;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var setups = await _scoringEngine.AnalyzeIsinAsync(entry.Isin, entry.Symbol, entry.Source, entry.AddedAtUtc, stoppingToken);
|
||||||
|
if (setups.Count > 0)
|
||||||
|
{
|
||||||
|
await _logger.LogInfoAsync(SettingKeys.TechnicalAnalysisChannel,
|
||||||
|
"[TechnicalScanner] Found {Count} active setup(s) for ISIN {Isin} (Top Score: {Score:F1})",
|
||||||
|
setups.Count, entry.Isin, setups[0].QualityScore);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
await _logger.LogWarningAsync(SettingKeys.TechnicalAnalysisChannel, ex,
|
||||||
|
"[TechnicalScanner] Error analyzing ISIN {Isin}", entry.Isin);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gentle throttle between asset analysis runs
|
||||||
|
await Task.Delay(250, stoppingToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
await _logger.LogInfoAsync(SettingKeys.TechnicalAnalysisChannel,
|
||||||
|
"[TechnicalScanner] Scan universe is currently empty. Waiting for next cycle.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait 60 seconds before next full universe evaluation pass
|
||||||
|
await Task.Delay(TimeSpan.FromSeconds(60), stoppingToken);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
await _logger.LogErrorAsync(SettingKeys.TechnicalAnalysisChannel, ex,
|
||||||
|
"[TechnicalScanner] Unexpected error in scanner loop. Retrying in 30 seconds.");
|
||||||
|
await Task.Delay(TimeSpan.FromSeconds(30), stoppingToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await _logger.LogInfoAsync(SettingKeys.TechnicalAnalysisChannel,
|
||||||
|
"[TechnicalScanner] Technical Universe Scanner Background Service stopped.");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,580 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using FinlyticCore.Dtos.TechnicalAnalysis;
|
||||||
|
using FinlyticCore.Services;
|
||||||
|
using FinlyticTechnicals.Database;
|
||||||
|
using FinlyticTechnicals.Entities;
|
||||||
|
using FinlyticTechnicals.Indicators;
|
||||||
|
using FinlyticTechnicals.Patterns;
|
||||||
|
using FinlyticTechnicals.Strategies;
|
||||||
|
using FinlyticTechnicals.Util;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
|
||||||
|
namespace FinlyticTechnicals.Services;
|
||||||
|
|
||||||
|
public interface ITechnicalScoringEngine
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Evaluates technical setup, indicators, and patterns for an ISIN and returns trading setups.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="universeSource">
|
||||||
|
/// Which universe-selection mechanism this ISIN is currently monitored under (favorite/discovery/
|
||||||
|
/// sentiment-spike), if known - passed through onto the returned <see cref="StrategyResultDto"/>s and
|
||||||
|
/// persisted alongside them so downstream consumers (FinlyticEngine) can record why the asset was being
|
||||||
|
/// watched. <see langword="null"/> for an ad hoc analysis outside the scan universe.
|
||||||
|
/// </param>
|
||||||
|
/// <param name="universeEnteredAtUtc">When the ISIN entered that universe, alongside <paramref name="universeSource"/>.</param>
|
||||||
|
Task<List<StrategyResultDto>> AnalyzeIsinAsync(string isin, string? symbol = null, UniverseSource? universeSource = null, DateTime? universeEnteredAtUtc = null, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets full technical analysis including candles, calculated indicators, patterns, and signals for an ISIN.
|
||||||
|
/// </summary>
|
||||||
|
Task<TechnicalAnalysisDto?> GetTechnicalAnalysisDtoAsync(string isin, string? symbol = null, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets all active top-pick setups from the database.
|
||||||
|
/// </summary>
|
||||||
|
Task<List<StrategyResultDto>> GetActiveSetupsAsync(bool topPicksOnly = false, int limit = 50, decimal? minScore = null, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns the last <paramref name="limit"/> setups persisted for <paramref name="isin"/> across all scan
|
||||||
|
/// cycles, most recent first - regardless of <c>IsActive</c>/expiry/top-pick status, so a caller can see
|
||||||
|
/// the raw quality-score trend over time, including setups too weak to ever have reached the engine.
|
||||||
|
/// </summary>
|
||||||
|
Task<List<StrategyResultDto>> GetRecentSetupHistoryAsync(string isin, int limit = 8, CancellationToken cancellationToken = default);
|
||||||
|
}
|
||||||
|
|
||||||
|
public class TechnicalScoringEngine : ITechnicalScoringEngine
|
||||||
|
{
|
||||||
|
private readonly IServiceScopeFactory _scopeFactory;
|
||||||
|
private readonly IMultiTimeframeCandleAggregator _aggregator;
|
||||||
|
private readonly IYahooMarketDataScraper _yahooScraper;
|
||||||
|
private readonly IEnumerable<IPatternDetector> _patternDetectors;
|
||||||
|
private readonly IEnumerable<ITechnicalStrategy> _strategies;
|
||||||
|
private readonly IFinlyticLogger<TechnicalScoringEngine> _logger;
|
||||||
|
|
||||||
|
public TechnicalScoringEngine(
|
||||||
|
IServiceScopeFactory scopeFactory,
|
||||||
|
IMultiTimeframeCandleAggregator aggregator,
|
||||||
|
IYahooMarketDataScraper yahooScraper,
|
||||||
|
IEnumerable<IPatternDetector> patternDetectors,
|
||||||
|
IEnumerable<ITechnicalStrategy> strategies,
|
||||||
|
IFinlyticLogger<TechnicalScoringEngine> logger)
|
||||||
|
{
|
||||||
|
_scopeFactory = scopeFactory;
|
||||||
|
_aggregator = aggregator;
|
||||||
|
_yahooScraper = yahooScraper;
|
||||||
|
_patternDetectors = patternDetectors;
|
||||||
|
_strategies = strategies;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
public async Task<List<StrategyResultDto>> AnalyzeIsinAsync(string isin, string? symbol = null, UniverseSource? universeSource = null, DateTime? universeEnteredAtUtc = null, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(isin)) return [];
|
||||||
|
var cleanIsin = isin.Trim().ToUpperInvariant();
|
||||||
|
|
||||||
|
// 1. Resolve ticker symbol if needed
|
||||||
|
string targetSymbol = symbol ?? string.Empty;
|
||||||
|
if (string.IsNullOrWhiteSpace(targetSymbol))
|
||||||
|
{
|
||||||
|
targetSymbol = await _yahooScraper.ResolveTickerFromIsinAsync(cleanIsin, cancellationToken) ?? cleanIsin;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Ensure historical multi-timeframe candles are available in ring buffers
|
||||||
|
var candles15m = _aggregator.GetCandles(cleanIsin, "15m");
|
||||||
|
var candles1h = _aggregator.GetCandles(cleanIsin, "1h");
|
||||||
|
var candles1d = _aggregator.GetCandles(cleanIsin, "1d");
|
||||||
|
|
||||||
|
if (candles1d.Count < 20 || candles15m.Count < 10)
|
||||||
|
{
|
||||||
|
// Backfill deep history from Yahoo
|
||||||
|
var dailyRes = await _yahooScraper.FetchHistoricalCandlesAsync(targetSymbol, "1y", "1d", cancellationToken);
|
||||||
|
if (dailyRes.Count > 0)
|
||||||
|
{
|
||||||
|
var dailyDtos = dailyRes.Select(c => new CandleDto(c.Timestamp, c.Open, c.High, c.Low, c.Close, c.Volume)).ToList();
|
||||||
|
_aggregator.InitializeHistory(cleanIsin, "1d", dailyDtos);
|
||||||
|
}
|
||||||
|
|
||||||
|
var hourlyRes = await _yahooScraper.FetchHistoricalCandlesAsync(targetSymbol, "60d", "1h", cancellationToken);
|
||||||
|
if (hourlyRes.Count > 0)
|
||||||
|
{
|
||||||
|
var hourlyDtos = hourlyRes.Select(c => new CandleDto(c.Timestamp, c.Open, c.High, c.Low, c.Close, c.Volume)).ToList();
|
||||||
|
_aggregator.InitializeHistory(cleanIsin, "1h", hourlyDtos);
|
||||||
|
}
|
||||||
|
|
||||||
|
var min15Res = await _yahooScraper.FetchHistoricalCandlesAsync(targetSymbol, "10d", "15m", cancellationToken);
|
||||||
|
if (min15Res.Count > 0)
|
||||||
|
{
|
||||||
|
var min15Dtos = min15Res.Select(c => new CandleDto(c.Timestamp, c.Open, c.High, c.Low, c.Close, c.Volume)).ToList();
|
||||||
|
_aggregator.InitializeHistory(cleanIsin, "15m", min15Dtos);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var allTimeframes = _aggregator.GetAllTimeframes(cleanIsin);
|
||||||
|
var primaryCandles = _aggregator.GetCandles(cleanIsin, "15m");
|
||||||
|
if (primaryCandles.Count == 0)
|
||||||
|
{
|
||||||
|
primaryCandles = _aggregator.GetCandles(cleanIsin, "1d");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (primaryCandles.Count < 5)
|
||||||
|
{
|
||||||
|
await _logger.LogWarningAsync(SettingKeys.TechnicalAnalysisChannel, "[TechnicalScoringEngine] Insufficient candles for ISIN {Isin}", cleanIsin);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
var lastCandle = primaryCandles.Last();
|
||||||
|
decimal currentAtr = TechnicalIndicatorsEngine.CalculateAtr(primaryCandles, 14);
|
||||||
|
var adx = TechnicalIndicatorsEngine.CalculateAdx(primaryCandles, 14);
|
||||||
|
decimal ema20 = TechnicalIndicatorsEngine.CalculateEma(primaryCandles, 20);
|
||||||
|
decimal ema50 = TechnicalIndicatorsEngine.CalculateEma(primaryCandles, 50);
|
||||||
|
|
||||||
|
// Determine Market Regime
|
||||||
|
MarketRegime regime = MarketRegime.LowVolatilityRangebound;
|
||||||
|
if (adx.IsTrending)
|
||||||
|
{
|
||||||
|
regime = ema20 > ema50 ? MarketRegime.BullishTrending : MarketRegime.BearishTrending;
|
||||||
|
}
|
||||||
|
else if (currentAtr > (lastCandle.Close * 0.03m))
|
||||||
|
{
|
||||||
|
regime = MarketRegime.HighVolatilityChoppy;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build TechnicalContext
|
||||||
|
var indicators = new Dictionary<string, decimal>(StringComparer.OrdinalIgnoreCase)
|
||||||
|
{
|
||||||
|
["EMA_20"] = ema20,
|
||||||
|
["EMA_50"] = ema50,
|
||||||
|
["EMA_200"] = TechnicalIndicatorsEngine.CalculateEma(primaryCandles, 200),
|
||||||
|
["RSI_14"] = TechnicalIndicatorsEngine.CalculateRsi(primaryCandles, 14),
|
||||||
|
["ATR_14"] = currentAtr,
|
||||||
|
["ADX_14"] = adx.Adx,
|
||||||
|
["VWAP"] = TechnicalIndicatorsEngine.CalculateVwap(primaryCandles)
|
||||||
|
};
|
||||||
|
|
||||||
|
var context = new TechnicalContext
|
||||||
|
{
|
||||||
|
Isin = cleanIsin,
|
||||||
|
Symbol = targetSymbol,
|
||||||
|
Timeframe = "15m",
|
||||||
|
TimestampUtc = lastCandle.Timestamp,
|
||||||
|
CurrentPrice = lastCandle.Close,
|
||||||
|
CurrentSpread = 0m,
|
||||||
|
IsSpreadVolatile = false,
|
||||||
|
CurrentAtr = currentAtr,
|
||||||
|
Regime = regime,
|
||||||
|
MultiTimeframeCandles = allTimeframes,
|
||||||
|
Indicators = indicators
|
||||||
|
};
|
||||||
|
|
||||||
|
// 3. Run all Pattern Detectors
|
||||||
|
var detectedPatterns = new List<PatternResultDto>();
|
||||||
|
foreach (var detector in _patternDetectors)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var pat = detector.Evaluate(context);
|
||||||
|
if (pat != null)
|
||||||
|
{
|
||||||
|
detectedPatterns.Add(pat);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
await _logger.LogWarningAsync(SettingKeys.TechnicalAnalysisChannel, ex, "[TechnicalScoringEngine] Pattern detector {Detector} threw an exception for ISIN {Isin}", detector.GetType().Name, cleanIsin);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Run all Strategies
|
||||||
|
var evaluatedSetups = new List<StrategyResultDto>();
|
||||||
|
foreach (var strategy in _strategies.OrderBy(s => s.Priority))
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (!strategy.IsApplicable(regime)) continue;
|
||||||
|
|
||||||
|
var setup = strategy.Evaluate(context, detectedPatterns);
|
||||||
|
if (setup != null)
|
||||||
|
{
|
||||||
|
// Confluence Scoring Calculation:
|
||||||
|
// FinalScore = 0.35 * S_ind + 0.35 * S_pattern + 0.30 * S_strat
|
||||||
|
decimal indicatorScore = CalculateIndicatorConfluenceScore(indicators, setup.Direction);
|
||||||
|
decimal patternScore = detectedPatterns.Count > 0 ? detectedPatterns.Average(p => p.QualityScore) : 50m;
|
||||||
|
decimal strategyBaseScore = setup.QualityScore;
|
||||||
|
|
||||||
|
decimal finalScore = (0.35m * indicatorScore) + (0.35m * patternScore) + (0.30m * strategyBaseScore);
|
||||||
|
finalScore = Math.Clamp(finalScore, 0m, 100m);
|
||||||
|
|
||||||
|
bool isTopPick = finalScore >= 75.0m;
|
||||||
|
string rating = finalScore >= 85.0m ? "A+" :
|
||||||
|
finalScore >= 75.0m ? "A" :
|
||||||
|
finalScore >= 60.0m ? "B" : "C";
|
||||||
|
|
||||||
|
var scoredSetup = setup with
|
||||||
|
{
|
||||||
|
QualityScore = finalScore,
|
||||||
|
IsTopPick = isTopPick,
|
||||||
|
Rating = rating,
|
||||||
|
UniverseSource = universeSource,
|
||||||
|
UniverseEnteredAtUtc = universeEnteredAtUtc,
|
||||||
|
Regime = regime
|
||||||
|
};
|
||||||
|
|
||||||
|
evaluatedSetups.Add(scoredSetup);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
await _logger.LogWarningAsync(SettingKeys.TechnicalAnalysisChannel, ex, "[TechnicalScoringEngine] Strategy {Strategy} threw an exception for ISIN {Isin}", strategy.StrategyKey, cleanIsin);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Persist Setups and Patterns into PostgreSQL
|
||||||
|
await PersistResultsAsync(cleanIsin, targetSymbol, detectedPatterns, evaluatedSetups);
|
||||||
|
|
||||||
|
return evaluatedSetups;
|
||||||
|
}
|
||||||
|
|
||||||
|
private decimal CalculateIndicatorConfluenceScore(Dictionary<string, decimal> ind, SignalDirection dir)
|
||||||
|
{
|
||||||
|
decimal score = 50m;
|
||||||
|
if (dir == SignalDirection.Buy)
|
||||||
|
{
|
||||||
|
if (ind.TryGetValue("EMA_20", out var e20) && ind.TryGetValue("EMA_50", out var e50) && e20 > e50) score += 15m;
|
||||||
|
if (ind.TryGetValue("RSI_14", out var rsi) && rsi is >= 45m and <= 65m) score += 15m;
|
||||||
|
if (ind.TryGetValue("ADX_14", out var adx) && adx >= 25m) score += 10m;
|
||||||
|
if (ind.TryGetValue("VWAP", out var vwap) && ind.TryGetValue("EMA_20", out var e20b) && e20b > vwap) score += 10m;
|
||||||
|
}
|
||||||
|
else if (dir == SignalDirection.Sell)
|
||||||
|
{
|
||||||
|
if (ind.TryGetValue("EMA_20", out var e20) && ind.TryGetValue("EMA_50", out var e50) && e20 < e50) score += 15m;
|
||||||
|
if (ind.TryGetValue("RSI_14", out var rsi) && rsi is >= 35m and <= 55m) score += 15m;
|
||||||
|
if (ind.TryGetValue("ADX_14", out var adx) && adx >= 25m) score += 10m;
|
||||||
|
}
|
||||||
|
return Math.Clamp(score, 0m, 100m);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task PersistResultsAsync(string isin, string symbol, List<PatternResultDto> patterns, List<StrategyResultDto> setups)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var scope = _scopeFactory.CreateScope();
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<TechnicalAnalysisDbContext>();
|
||||||
|
|
||||||
|
// Save detected patterns
|
||||||
|
foreach (var pat in patterns)
|
||||||
|
{
|
||||||
|
db.FtaDetectedPatterns.Add(new FtaDetectedPatternEntity
|
||||||
|
{
|
||||||
|
Id = pat.Id,
|
||||||
|
Isin = isin,
|
||||||
|
Timeframe = pat.Timeframe,
|
||||||
|
PatternType = pat.Type.ToString(),
|
||||||
|
Category = pat.Category.ToString(),
|
||||||
|
Bias = pat.Bias.ToString(),
|
||||||
|
Name = pat.Name,
|
||||||
|
KeyPriceLevel = pat.KeyPriceLevel,
|
||||||
|
UpperBoundary = pat.UpperBoundary,
|
||||||
|
LowerBoundary = pat.LowerBoundary,
|
||||||
|
InvalidationLevel = pat.InvalidationLevel,
|
||||||
|
QualityScore = pat.QualityScore,
|
||||||
|
Description = pat.Description,
|
||||||
|
ExtraData = pat.ExtraData,
|
||||||
|
DetectedAtUtc = pat.DetectedAt
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save strategy setups
|
||||||
|
foreach (var setup in setups)
|
||||||
|
{
|
||||||
|
db.FtaTechnicalSetups.Add(new FtaTechnicalSetupEntity
|
||||||
|
{
|
||||||
|
SetupId = setup.SetupId,
|
||||||
|
Isin = isin,
|
||||||
|
Symbol = symbol,
|
||||||
|
Timeframe = setup.Timeframe,
|
||||||
|
StrategyKey = setup.StrategyKey,
|
||||||
|
StrategyName = setup.StrategyName,
|
||||||
|
Direction = setup.Direction.ToString(),
|
||||||
|
QualityScore = setup.QualityScore,
|
||||||
|
CurrentPrice = setup.CurrentPrice,
|
||||||
|
EntryPrice = setup.EntryPrice,
|
||||||
|
InvalidationPrice = setup.InvalidationPrice,
|
||||||
|
CurrentAtr = setup.CurrentAtr,
|
||||||
|
EstimatedRiskRewardRatio = setup.EstimatedRiskRewardRatio,
|
||||||
|
ExitPlan = setup.ExitPlan,
|
||||||
|
TechnicalRationale = setup.TechnicalRationale,
|
||||||
|
TriggeringPatterns = setup.TriggeringPatterns,
|
||||||
|
IndicatorSnapshot = setup.IndicatorSnapshot,
|
||||||
|
IsTopPick = setup.IsTopPick,
|
||||||
|
Rating = setup.Rating,
|
||||||
|
IsActive = true,
|
||||||
|
CreatedAtUtc = setup.CreatedAt,
|
||||||
|
ExpiresAtUtc = setup.ExpiresAt,
|
||||||
|
UniverseSource = setup.UniverseSource?.ToString(),
|
||||||
|
UniverseEnteredAtUtc = setup.UniverseEnteredAtUtc,
|
||||||
|
Regime = setup.Regime?.ToString()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
await _logger.LogWarningAsync(SettingKeys.TechnicalAnalysisChannel, ex, "[TechnicalScoringEngine] Error persisting patterns & setups for ISIN {Isin}", isin);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<List<StrategyResultDto>> GetActiveSetupsAsync(bool topPicksOnly = false, int limit = 50, decimal? minScore = null, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
using var scope = _scopeFactory.CreateScope();
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<TechnicalAnalysisDbContext>();
|
||||||
|
|
||||||
|
var now = DateTime.UtcNow;
|
||||||
|
var query = db.FtaTechnicalSetups
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(s => s.IsActive && s.ExpiresAtUtc > now);
|
||||||
|
|
||||||
|
if (topPicksOnly)
|
||||||
|
{
|
||||||
|
query = query.Where(s => s.IsTopPick);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (minScore.HasValue && minScore.Value > 0)
|
||||||
|
{
|
||||||
|
query = query.Where(s => s.QualityScore >= minScore.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
var entities = await query
|
||||||
|
.OrderByDescending(s => s.QualityScore)
|
||||||
|
.Take(limit)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
|
||||||
|
return entities.Select(e => new StrategyResultDto(
|
||||||
|
SetupId: e.SetupId,
|
||||||
|
Isin: e.Isin,
|
||||||
|
Symbol: e.Symbol,
|
||||||
|
Timeframe: e.Timeframe,
|
||||||
|
StrategyKey: e.StrategyKey,
|
||||||
|
StrategyName: e.StrategyName,
|
||||||
|
Direction: Enum.TryParse<SignalDirection>(e.Direction, out var dir) ? dir : SignalDirection.Buy,
|
||||||
|
QualityScore: e.QualityScore,
|
||||||
|
CurrentPrice: e.CurrentPrice,
|
||||||
|
EntryPrice: e.EntryPrice,
|
||||||
|
InvalidationPrice: e.InvalidationPrice,
|
||||||
|
CurrentAtr: e.CurrentAtr,
|
||||||
|
EstimatedRiskRewardRatio: e.EstimatedRiskRewardRatio,
|
||||||
|
ExitPlan: e.ExitPlan,
|
||||||
|
TechnicalRationale: e.TechnicalRationale,
|
||||||
|
TriggeringPatterns: e.TriggeringPatterns ?? [],
|
||||||
|
IndicatorSnapshot: e.IndicatorSnapshot ?? [],
|
||||||
|
CreatedAt: e.CreatedAtUtc,
|
||||||
|
ExpiresAt: e.ExpiresAtUtc,
|
||||||
|
IsTopPick: e.IsTopPick,
|
||||||
|
Rating: e.Rating,
|
||||||
|
UniverseSource: Enum.TryParse<UniverseSource>(e.UniverseSource, out var universeSource) ? universeSource : null,
|
||||||
|
UniverseEnteredAtUtc: e.UniverseEnteredAtUtc,
|
||||||
|
Regime: Enum.TryParse<MarketRegime>(e.Regime, out var regimeParsed) ? regimeParsed : null
|
||||||
|
)).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<List<StrategyResultDto>> GetRecentSetupHistoryAsync(string isin, int limit = 8, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(isin)) return [];
|
||||||
|
var cleanIsin = isin.Trim().ToUpperInvariant();
|
||||||
|
|
||||||
|
using var scope = _scopeFactory.CreateScope();
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<TechnicalAnalysisDbContext>();
|
||||||
|
|
||||||
|
var entities = await db.FtaTechnicalSetups
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(s => s.Isin == cleanIsin)
|
||||||
|
.OrderByDescending(s => s.CreatedAtUtc)
|
||||||
|
.Take(limit)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
return entities.Select(e => new StrategyResultDto(
|
||||||
|
SetupId: e.SetupId,
|
||||||
|
Isin: e.Isin,
|
||||||
|
Symbol: e.Symbol,
|
||||||
|
Timeframe: e.Timeframe,
|
||||||
|
StrategyKey: e.StrategyKey,
|
||||||
|
StrategyName: e.StrategyName,
|
||||||
|
Direction: Enum.TryParse<SignalDirection>(e.Direction, out var dir) ? dir : SignalDirection.Buy,
|
||||||
|
QualityScore: e.QualityScore,
|
||||||
|
CurrentPrice: e.CurrentPrice,
|
||||||
|
EntryPrice: e.EntryPrice,
|
||||||
|
InvalidationPrice: e.InvalidationPrice,
|
||||||
|
CurrentAtr: e.CurrentAtr,
|
||||||
|
EstimatedRiskRewardRatio: e.EstimatedRiskRewardRatio,
|
||||||
|
ExitPlan: e.ExitPlan,
|
||||||
|
TechnicalRationale: e.TechnicalRationale,
|
||||||
|
TriggeringPatterns: e.TriggeringPatterns ?? [],
|
||||||
|
IndicatorSnapshot: e.IndicatorSnapshot ?? [],
|
||||||
|
CreatedAt: e.CreatedAtUtc,
|
||||||
|
ExpiresAt: e.ExpiresAtUtc,
|
||||||
|
IsTopPick: e.IsTopPick,
|
||||||
|
Rating: e.Rating,
|
||||||
|
UniverseSource: Enum.TryParse<UniverseSource>(e.UniverseSource, out var universeSource) ? universeSource : null,
|
||||||
|
UniverseEnteredAtUtc: e.UniverseEnteredAtUtc,
|
||||||
|
Regime: Enum.TryParse<MarketRegime>(e.Regime, out var regimeParsed) ? regimeParsed : null
|
||||||
|
)).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<TechnicalAnalysisDto?> GetTechnicalAnalysisDtoAsync(string isin, string? symbol = null, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(isin)) return null;
|
||||||
|
var cleanIsin = isin.Trim().ToUpperInvariant();
|
||||||
|
|
||||||
|
// 1. Resolve ticker symbol if needed
|
||||||
|
string targetSymbol = symbol ?? string.Empty;
|
||||||
|
if (string.IsNullOrWhiteSpace(targetSymbol))
|
||||||
|
{
|
||||||
|
targetSymbol = await _yahooScraper.ResolveTickerFromIsinAsync(cleanIsin, cancellationToken) ?? cleanIsin;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Ensure historical multi-timeframe candles & setups are calculated
|
||||||
|
var evaluatedSetups = await AnalyzeIsinAsync(cleanIsin, targetSymbol, cancellationToken: cancellationToken);
|
||||||
|
|
||||||
|
var candles1d = _aggregator.GetCandles(cleanIsin, "1d");
|
||||||
|
var primaryCandles = candles1d.Count > 0 ? candles1d : _aggregator.GetCandles(cleanIsin, "15m");
|
||||||
|
if (primaryCandles.Count == 0)
|
||||||
|
{
|
||||||
|
primaryCandles = _aggregator.GetCandles(cleanIsin, "1h");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (primaryCandles.Count == 0)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var lastCandle = primaryCandles.Last();
|
||||||
|
decimal currentAtr = TechnicalIndicatorsEngine.CalculateAtr(primaryCandles, 14);
|
||||||
|
var adx = TechnicalIndicatorsEngine.CalculateAdx(primaryCandles, 14);
|
||||||
|
decimal ema20 = TechnicalIndicatorsEngine.CalculateEma(primaryCandles, 20);
|
||||||
|
decimal ema50 = TechnicalIndicatorsEngine.CalculateEma(primaryCandles, 50);
|
||||||
|
|
||||||
|
MarketRegime regime = MarketRegime.LowVolatilityRangebound;
|
||||||
|
if (adx.IsTrending)
|
||||||
|
{
|
||||||
|
regime = ema20 > ema50 ? MarketRegime.BullishTrending : MarketRegime.BearishTrending;
|
||||||
|
}
|
||||||
|
else if (currentAtr > (lastCandle.Close * 0.03m))
|
||||||
|
{
|
||||||
|
regime = MarketRegime.HighVolatilityChoppy;
|
||||||
|
}
|
||||||
|
|
||||||
|
var context = new TechnicalContext
|
||||||
|
{
|
||||||
|
Isin = cleanIsin,
|
||||||
|
Symbol = targetSymbol,
|
||||||
|
Timeframe = "1d",
|
||||||
|
TimestampUtc = lastCandle.Timestamp,
|
||||||
|
CurrentPrice = lastCandle.Close,
|
||||||
|
CurrentSpread = 0m,
|
||||||
|
IsSpreadVolatile = false,
|
||||||
|
CurrentAtr = currentAtr,
|
||||||
|
Regime = regime,
|
||||||
|
MultiTimeframeCandles = _aggregator.GetAllTimeframes(cleanIsin),
|
||||||
|
Indicators = new Dictionary<string, decimal>(StringComparer.OrdinalIgnoreCase)
|
||||||
|
{
|
||||||
|
["EMA_20"] = ema20,
|
||||||
|
["EMA_50"] = ema50,
|
||||||
|
["EMA_200"] = TechnicalIndicatorsEngine.CalculateEma(primaryCandles, 200),
|
||||||
|
["RSI_14"] = TechnicalIndicatorsEngine.CalculateRsi(primaryCandles, 14),
|
||||||
|
["ATR_14"] = currentAtr,
|
||||||
|
["ADX_14"] = adx.Adx,
|
||||||
|
["VWAP"] = TechnicalIndicatorsEngine.CalculateVwap(primaryCandles)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
var detectedPatterns = new List<PatternResultDto>();
|
||||||
|
foreach (var detector in _patternDetectors)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var pat = detector.Evaluate(context);
|
||||||
|
if (pat != null)
|
||||||
|
{
|
||||||
|
detectedPatterns.Add(pat);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch { }
|
||||||
|
}
|
||||||
|
|
||||||
|
var indicatorList = new List<IndicatorValuesDto>();
|
||||||
|
var candlesList = primaryCandles.ToList();
|
||||||
|
for (int i = 0; i < candlesList.Count; i++)
|
||||||
|
{
|
||||||
|
var slice = candlesList.Take(i + 1).ToList();
|
||||||
|
var c = candlesList[i];
|
||||||
|
var macd = TechnicalIndicatorsEngine.CalculateMacd(slice);
|
||||||
|
var st = TechnicalIndicatorsEngine.CalculateSuperTrend(slice);
|
||||||
|
var atr = TechnicalIndicatorsEngine.CalculateAtr(slice, 14);
|
||||||
|
|
||||||
|
indicatorList.Add(new IndicatorValuesDto(
|
||||||
|
Timestamp: c.Timestamp,
|
||||||
|
Ema20: TechnicalIndicatorsEngine.CalculateEma(slice, 20),
|
||||||
|
Sma50: TechnicalIndicatorsEngine.CalculateSma(slice, 50),
|
||||||
|
Sma200: TechnicalIndicatorsEngine.CalculateSma(slice, 200),
|
||||||
|
Rsi14: TechnicalIndicatorsEngine.CalculateRsi(slice, 14),
|
||||||
|
MacdLine: macd.MacdLine,
|
||||||
|
MacdSignal: macd.SignalLine,
|
||||||
|
MacdHistogram: macd.Histogram,
|
||||||
|
Atr14: atr,
|
||||||
|
Vwap: TechnicalIndicatorsEngine.CalculateVwap(slice),
|
||||||
|
SupertrendUpper: st.Direction == SignalDirection.Sell ? st.Value : null,
|
||||||
|
SupertrendLower: st.Direction == SignalDirection.Buy ? st.Value : null,
|
||||||
|
SupertrendDirection: st.Direction.ToString().ToUpperInvariant(),
|
||||||
|
RecommendedStopLoss: c.Close - (atr * 2m)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
var chartPatterns = detectedPatterns.Select(p => new ChartPatternDto(
|
||||||
|
Type: p.Type.ToString(),
|
||||||
|
Description: p.Description,
|
||||||
|
UpperLine: new List<PatternPointDto> { new(lastCandle.Timestamp.AddDays(-5), p.UpperBoundary > 0m ? p.UpperBoundary : lastCandle.High), new(lastCandle.Timestamp, p.UpperBoundary > 0m ? p.UpperBoundary : lastCandle.High) },
|
||||||
|
LowerLine: new List<PatternPointDto> { new(lastCandle.Timestamp.AddDays(-5), p.LowerBoundary > 0m ? p.LowerBoundary : lastCandle.Low), new(lastCandle.Timestamp, p.LowerBoundary > 0m ? p.LowerBoundary : lastCandle.Low) },
|
||||||
|
ApexTime: lastCandle.Timestamp,
|
||||||
|
BreakoutSignal: new BreakoutSignalDto(lastCandle.Timestamp, p.Bias.ToString().ToUpperInvariant(), p.KeyPriceLevel > 0m ? p.KeyPriceLevel : lastCandle.Close, p.KeyPriceLevel > 0m ? p.KeyPriceLevel * 1.05m : lastCandle.Close * 1.05m, 5.0m),
|
||||||
|
ConfidencePercent: p.QualityScore
|
||||||
|
)).ToList();
|
||||||
|
|
||||||
|
var strategySignals = evaluatedSetups.Select(s => new StrategySignalDto(
|
||||||
|
Type: s.StrategyKey,
|
||||||
|
Timestamp: s.CreatedAt,
|
||||||
|
Direction: s.Direction.ToString().ToUpperInvariant(),
|
||||||
|
Price: s.CurrentPrice,
|
||||||
|
Description: s.TechnicalRationale
|
||||||
|
)).ToList();
|
||||||
|
|
||||||
|
var marketRegimeDto = new MarketRegimeDto(
|
||||||
|
VixValue: 18.5m,
|
||||||
|
VixRegime: regime.ToString(),
|
||||||
|
MarketTrend: regime == MarketRegime.BullishTrending ? "Bullish" : regime == MarketRegime.BearishTrending ? "Bearish" : "Neutral",
|
||||||
|
DxyValue: 104.2m,
|
||||||
|
DxyState: "Neutral",
|
||||||
|
SummaryText: $"Market Regime: {regime} with ATR {currentAtr:F2}"
|
||||||
|
);
|
||||||
|
|
||||||
|
return new TechnicalAnalysisDto(
|
||||||
|
Isin: cleanIsin,
|
||||||
|
Ticker: targetSymbol,
|
||||||
|
CompanyName: targetSymbol,
|
||||||
|
LastUpdated: lastCandle.Timestamp,
|
||||||
|
Candles: candlesList,
|
||||||
|
Indicators: indicatorList,
|
||||||
|
Patterns: chartPatterns,
|
||||||
|
Signals: strategySignals,
|
||||||
|
MarketRegime: marketRegimeDto,
|
||||||
|
Currency: "EUR"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,283 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using FinlyticCore.Dtos.Assets;
|
||||||
|
using FinlyticCore.Dtos.TechnicalAnalysis;
|
||||||
|
using FinlyticCore.Models.Assets;
|
||||||
|
using FinlyticCore.Services;
|
||||||
|
using FinlyticTechnicals.Database;
|
||||||
|
using FinlyticTechnicals.Entities;
|
||||||
|
using FinlyticTechnicals.Util;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
|
||||||
|
namespace FinlyticTechnicals.Services;
|
||||||
|
|
||||||
|
public record MonitoredUniverseEntry(
|
||||||
|
string Isin,
|
||||||
|
string? Symbol,
|
||||||
|
UniverseSource Source,
|
||||||
|
DateTime AddedAtUtc,
|
||||||
|
DateTime? ExpiresAtUtc,
|
||||||
|
int Priority
|
||||||
|
);
|
||||||
|
|
||||||
|
public interface ITechnicalUniverseManager
|
||||||
|
{
|
||||||
|
Task AddOrUpdateAssetAsync(string isin, string? symbol, UniverseSource source, int priority, TimeSpan? ttl = null, CancellationToken cancellationToken = default);
|
||||||
|
Task RemoveExpiredAsync(CancellationToken cancellationToken = default);
|
||||||
|
Task<IReadOnlyList<MonitoredUniverseEntry>> GetActiveUniverseAsync(CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Looks up the current universe entry for a single ISIN, if it is currently monitored. Used by
|
||||||
|
/// <c>TAMqttClient</c> to attach <see cref="MonitoredUniverseEntry.Source"/>/<see cref="MonitoredUniverseEntry.AddedAtUtc"/>
|
||||||
|
/// onto an on-demand <c>ta_GetSetupsForIsin</c> analysis, so the caller (FinlyticEngine) can record why the
|
||||||
|
/// asset was being watched in the first place. Returns <see langword="null"/> if the ISIN is not currently
|
||||||
|
/// in the universe (e.g. a manual "Analyze now" call for an asset nobody favorited/discovered/spiked).
|
||||||
|
/// </summary>
|
||||||
|
Task<MonitoredUniverseEntry?> GetEntryAsync(string isin, CancellationToken cancellationToken = default);
|
||||||
|
Task RefreshFavoritesAsync(CancellationToken cancellationToken = default);
|
||||||
|
Task RefreshDiscoveryAsync(CancellationToken cancellationToken = default);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Maintains the prioritized set of ISINs FinlyticTechnicals continuously scans (favorites aggregated across
|
||||||
|
/// all users, FinlyticAssets' curated discovery list, and temporary sentiment-spike promotions), backed by the
|
||||||
|
/// <c>fta_monitored_universe_assets</c> table rather than an in-memory collection so the current universe is
|
||||||
|
/// inspectable in the database while the service is running. The table is deliberately wiped on every service
|
||||||
|
/// startup (see <c>Program.cs</c>) - it is fully rebuilt within minutes from
|
||||||
|
/// <see cref="RefreshFavoritesAsync"/>/<see cref="RefreshDiscoveryAsync"/> and fresh sentiment-spike events, so
|
||||||
|
/// nothing of value would survive a restart anyway.
|
||||||
|
/// </summary>
|
||||||
|
public class TechnicalUniverseManager : ITechnicalUniverseManager
|
||||||
|
{
|
||||||
|
private readonly IServiceScopeFactory _scopeFactory;
|
||||||
|
private readonly ITAMqttRpcClient _rpcClient;
|
||||||
|
private readonly IFinlyticLogger<TechnicalUniverseManager> _logger;
|
||||||
|
|
||||||
|
public TechnicalUniverseManager(
|
||||||
|
IServiceScopeFactory scopeFactory,
|
||||||
|
ITAMqttRpcClient rpcClient,
|
||||||
|
IFinlyticLogger<TechnicalUniverseManager> logger)
|
||||||
|
{
|
||||||
|
_scopeFactory = scopeFactory;
|
||||||
|
_rpcClient = rpcClient;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task AddOrUpdateAssetAsync(string isin, string? symbol, UniverseSource source, int priority, TimeSpan? ttl = null, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(isin)) return;
|
||||||
|
|
||||||
|
var cleanIsin = isin.Trim().ToUpperInvariant();
|
||||||
|
DateTime now = DateTime.UtcNow;
|
||||||
|
DateTime? expiresAt = ttl.HasValue ? now.Add(ttl.Value) : null;
|
||||||
|
|
||||||
|
using var scope = _scopeFactory.CreateScope();
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<TechnicalAnalysisDbContext>();
|
||||||
|
|
||||||
|
var existing = await db.MonitoredUniverseAssets.FirstOrDefaultAsync(e => e.Isin == cleanIsin, cancellationToken);
|
||||||
|
if (existing == null)
|
||||||
|
{
|
||||||
|
db.MonitoredUniverseAssets.Add(new FtaMonitoredUniverseAssetEntity
|
||||||
|
{
|
||||||
|
Isin = cleanIsin,
|
||||||
|
Symbol = symbol,
|
||||||
|
Source = source.ToString(),
|
||||||
|
Priority = priority,
|
||||||
|
AddedAtUtc = now,
|
||||||
|
ExpiresAtUtc = expiresAt
|
||||||
|
});
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Keep the highest priority (lower int value = higher priority), matching the original in-memory
|
||||||
|
// ConcurrentDictionary.AddOrUpdate semantics this table replaced.
|
||||||
|
int bestPriority = Math.Min(existing.Priority, priority);
|
||||||
|
if (bestPriority == priority)
|
||||||
|
{
|
||||||
|
existing.Source = source.ToString();
|
||||||
|
}
|
||||||
|
existing.Priority = bestPriority;
|
||||||
|
existing.Symbol = symbol ?? existing.Symbol;
|
||||||
|
existing.ExpiresAtUtc = expiresAt != null && (existing.ExpiresAtUtc == null || expiresAt > existing.ExpiresAtUtc)
|
||||||
|
? expiresAt
|
||||||
|
: existing.ExpiresAtUtc;
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
|
await _logger.LogInfoAsync(SettingKeys.TechnicalAnalysisChannel,
|
||||||
|
"[UniverseManager] Added/Updated asset {Isin} (Source: {Source}, Priority: {Priority}, TTL: {TTL}m)",
|
||||||
|
cleanIsin, source, priority, ttl?.TotalMinutes ?? 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task RemoveExpiredAsync(CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
using var scope = _scopeFactory.CreateScope();
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<TechnicalAnalysisDbContext>();
|
||||||
|
|
||||||
|
DateTime now = DateTime.UtcNow;
|
||||||
|
var expired = await db.MonitoredUniverseAssets
|
||||||
|
.Where(e => e.ExpiresAtUtc.HasValue && e.ExpiresAtUtc.Value <= now)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
if (expired.Count == 0) return;
|
||||||
|
|
||||||
|
db.MonitoredUniverseAssets.RemoveRange(expired);
|
||||||
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
|
foreach (var removed in expired)
|
||||||
|
{
|
||||||
|
await _logger.LogInfoAsync(SettingKeys.TechnicalAnalysisChannel,
|
||||||
|
"[UniverseManager] Expired temporary asset {Isin} (Source: {Source}) removed from scan universe.",
|
||||||
|
removed.Isin, removed.Source);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<MonitoredUniverseEntry>> GetActiveUniverseAsync(CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
await RemoveExpiredAsync(cancellationToken);
|
||||||
|
|
||||||
|
using var scope = _scopeFactory.CreateScope();
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<TechnicalAnalysisDbContext>();
|
||||||
|
|
||||||
|
var entities = await db.MonitoredUniverseAssets
|
||||||
|
.AsNoTracking()
|
||||||
|
.OrderBy(e => e.Priority)
|
||||||
|
.ThenByDescending(e => e.AddedAtUtc)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
return entities.Select(ToEntry).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<MonitoredUniverseEntry?> GetEntryAsync(string isin, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(isin)) return null;
|
||||||
|
var cleanIsin = isin.Trim().ToUpperInvariant();
|
||||||
|
|
||||||
|
using var scope = _scopeFactory.CreateScope();
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<TechnicalAnalysisDbContext>();
|
||||||
|
|
||||||
|
var entity = await db.MonitoredUniverseAssets.AsNoTracking().FirstOrDefaultAsync(e => e.Isin == cleanIsin, cancellationToken);
|
||||||
|
return entity == null ? null : ToEntry(entity);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task RefreshFavoritesAsync(CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var isins = await _rpcClient.SendRpcRequestAsync<List<string>, string>(
|
||||||
|
"backend_GetAggregatedFavorites",
|
||||||
|
string.Empty,
|
||||||
|
TimeSpan.FromSeconds(5)
|
||||||
|
);
|
||||||
|
|
||||||
|
var freshSet = ToCleanIsinSet(isins);
|
||||||
|
int prunedCount = await UpsertSourceBatchAsync(UniverseSource.UserFavorite, priority: 2, freshSet, cancellationToken);
|
||||||
|
|
||||||
|
await _logger.LogInfoAsync(SettingKeys.TechnicalAnalysisChannel,
|
||||||
|
"[UniverseManager] Synced {Count} user favorite ISINs from FinlyticBackend ({Pruned} stale entries pruned).",
|
||||||
|
freshSet.Count, prunedCount);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
await _logger.LogWarningAsync(SettingKeys.TechnicalAnalysisChannel, ex,
|
||||||
|
"[UniverseManager] Failed to refresh user favorites from FinlyticBackend via RPC.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task RefreshDiscoveryAsync(CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var req = new GetDiscoveryAssetsRequest(Limit: 35);
|
||||||
|
var discoveryAssets = await _rpcClient.SendRpcRequestAsync<List<AssetDto>, GetDiscoveryAssetsRequest>(
|
||||||
|
"assets_GetDiscovery",
|
||||||
|
req,
|
||||||
|
TimeSpan.FromSeconds(5)
|
||||||
|
);
|
||||||
|
|
||||||
|
var freshSet = ToCleanIsinSet(discoveryAssets?.Select(a => a.Isin));
|
||||||
|
int prunedCount = await UpsertSourceBatchAsync(UniverseSource.Discovery, priority: 3, freshSet, cancellationToken);
|
||||||
|
|
||||||
|
await _logger.LogInfoAsync(SettingKeys.TechnicalAnalysisChannel,
|
||||||
|
"[UniverseManager] Synced {Count} discovery assets from FinlyticAssets ({Pruned} stale entries pruned).",
|
||||||
|
freshSet.Count, prunedCount);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
await _logger.LogWarningAsync(SettingKeys.TechnicalAnalysisChannel, ex,
|
||||||
|
"[UniverseManager] Failed to refresh discovery assets from FinlyticAssets via RPC.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static HashSet<string> ToCleanIsinSet(IEnumerable<string>? isins)
|
||||||
|
{
|
||||||
|
return new HashSet<string>(
|
||||||
|
(isins ?? []).Where(i => !string.IsNullOrWhiteSpace(i)).Select(i => i.Trim().ToUpperInvariant()),
|
||||||
|
StringComparer.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static MonitoredUniverseEntry ToEntry(FtaMonitoredUniverseAssetEntity e) => new(
|
||||||
|
e.Isin, e.Symbol,
|
||||||
|
Enum.TryParse<UniverseSource>(e.Source, out var src) ? src : UniverseSource.Discovery,
|
||||||
|
e.AddedAtUtc, e.ExpiresAtUtc, e.Priority
|
||||||
|
);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Upserts every ISIN in <paramref name="freshIsins"/> under <paramref name="source"/>/<paramref name="priority"/>
|
||||||
|
/// in a single batch, and removes rows still tagged with <paramref name="source"/> whose ISIN is no longer
|
||||||
|
/// present in <paramref name="freshIsins"/> - i.e. an asset the latest refresh no longer reports (a user
|
||||||
|
/// unfavorited it, or it dropped out of discovery). A row that meanwhile got promoted to a different source
|
||||||
|
/// (e.g. a live sentiment spike) is left alone: its Source column no longer matches, so it survives on its
|
||||||
|
/// own TTL instead of being pruned here. Returns the number of stale rows pruned.
|
||||||
|
/// </summary>
|
||||||
|
private async Task<int> UpsertSourceBatchAsync(UniverseSource source, int priority, HashSet<string> freshIsins, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
using var scope = _scopeFactory.CreateScope();
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<TechnicalAnalysisDbContext>();
|
||||||
|
|
||||||
|
var now = DateTime.UtcNow;
|
||||||
|
var sourceTag = source.ToString();
|
||||||
|
|
||||||
|
var all = await db.MonitoredUniverseAssets.ToListAsync(cancellationToken);
|
||||||
|
var byIsin = all.ToDictionary(e => e.Isin, e => e, StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
foreach (var isin in freshIsins)
|
||||||
|
{
|
||||||
|
if (byIsin.TryGetValue(isin, out var existing))
|
||||||
|
{
|
||||||
|
int bestPriority = Math.Min(existing.Priority, priority);
|
||||||
|
if (bestPriority == priority)
|
||||||
|
{
|
||||||
|
existing.Source = sourceTag;
|
||||||
|
}
|
||||||
|
existing.Priority = bestPriority;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
db.MonitoredUniverseAssets.Add(new FtaMonitoredUniverseAssetEntity
|
||||||
|
{
|
||||||
|
Isin = isin,
|
||||||
|
Symbol = null,
|
||||||
|
Source = sourceTag,
|
||||||
|
Priority = priority,
|
||||||
|
AddedAtUtc = now,
|
||||||
|
ExpiresAtUtc = null
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var stale = all.Where(e => e.Source == sourceTag && !freshIsins.Contains(e.Isin)).ToList();
|
||||||
|
if (stale.Count > 0)
|
||||||
|
{
|
||||||
|
db.MonitoredUniverseAssets.RemoveRange(stale);
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
|
return stale.Count;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
using System;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using FinlyticCore.Dtos.TechnicalAnalysis;
|
||||||
|
using FinlyticCore.Services;
|
||||||
|
using FinlyticTechnicals.Util;
|
||||||
|
|
||||||
|
namespace FinlyticTechnicals.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Cleaned, normalized real-time tick ready for multi-timeframe aggregation.
|
||||||
|
/// </summary>
|
||||||
|
public record CleanLiveTick(
|
||||||
|
string Isin,
|
||||||
|
decimal MidPrice,
|
||||||
|
decimal Bid,
|
||||||
|
decimal Ask,
|
||||||
|
decimal LastPrice,
|
||||||
|
decimal SpreadPercent,
|
||||||
|
bool IsSpreadVolatile,
|
||||||
|
DateTime TimestampUtc
|
||||||
|
);
|
||||||
|
|
||||||
|
public interface ITradeRepublicIngestionService
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Event triggered when a cleaned, UTC-normalized tick arrives.
|
||||||
|
/// </summary>
|
||||||
|
event Func<CleanLiveTick, Task>? OnTickReceived;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Processes a raw tick from Trade Republic (e.g. via WebSocket or Poller).
|
||||||
|
/// </summary>
|
||||||
|
Task<CleanLiveTick?> ProcessRawTickAsync(string isin, decimal bid, decimal ask, decimal? last, DateTime? timestamp, CancellationToken cancellationToken = default);
|
||||||
|
}
|
||||||
|
|
||||||
|
public class TradeRepublicIngestionService : ITradeRepublicIngestionService
|
||||||
|
{
|
||||||
|
private readonly IFinlyticLogger<TradeRepublicIngestionService> _finlyticLogger;
|
||||||
|
private static readonly TimeZoneInfo BerlinTimeZone = GetBerlinTimeZone();
|
||||||
|
|
||||||
|
public event Func<CleanLiveTick, Task>? OnTickReceived;
|
||||||
|
|
||||||
|
public TradeRepublicIngestionService(IFinlyticLogger<TradeRepublicIngestionService> finlyticLogger)
|
||||||
|
{
|
||||||
|
_finlyticLogger = finlyticLogger;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Processes a raw incoming tick with strict UTC normalization, spread check, and mid-price calculation.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<CleanLiveTick?> ProcessRawTickAsync(string isin, decimal bid, decimal ask, decimal? last, DateTime? timestamp, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(isin)) return null;
|
||||||
|
|
||||||
|
var cleanIsin = isin.Trim().ToUpperInvariant();
|
||||||
|
|
||||||
|
// 1. Strict UTC Normalization
|
||||||
|
DateTime utcTimestamp;
|
||||||
|
if (timestamp.HasValue)
|
||||||
|
{
|
||||||
|
var rawTime = timestamp.Value;
|
||||||
|
if (rawTime.Kind == DateTimeKind.Utc)
|
||||||
|
{
|
||||||
|
utcTimestamp = rawTime;
|
||||||
|
}
|
||||||
|
else if (rawTime.Kind == DateTimeKind.Unspecified)
|
||||||
|
{
|
||||||
|
// Trade Republic ticks typically arrive in German local market time (Europe/Berlin)
|
||||||
|
utcTimestamp = TimeZoneInfo.ConvertTimeToUtc(rawTime, BerlinTimeZone);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
utcTimestamp = rawTime.ToUniversalTime();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
utcTimestamp = DateTime.UtcNow;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Clean Mid-Price Calculation: (Bid + Ask) / 2
|
||||||
|
decimal cleanMidPrice;
|
||||||
|
if (bid > 0m && ask > 0m)
|
||||||
|
{
|
||||||
|
cleanMidPrice = (bid + ask) / 2m;
|
||||||
|
}
|
||||||
|
else if (last.HasValue && last.Value > 0m)
|
||||||
|
{
|
||||||
|
cleanMidPrice = last.Value;
|
||||||
|
if (bid <= 0m) bid = cleanMidPrice;
|
||||||
|
if (ask <= 0m) ask = cleanMidPrice;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
return null; // Invalid quote
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Spread Calculation & Volatility Tagging
|
||||||
|
decimal spreadPercent = 0m;
|
||||||
|
bool isSpreadVolatile = false;
|
||||||
|
if (cleanMidPrice > 0m && ask >= bid)
|
||||||
|
{
|
||||||
|
spreadPercent = ((ask - bid) / cleanMidPrice) * 100m;
|
||||||
|
if (spreadPercent > 1.5m)
|
||||||
|
{
|
||||||
|
isSpreadVolatile = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var cleanTick = new CleanLiveTick(
|
||||||
|
Isin: cleanIsin,
|
||||||
|
MidPrice: cleanMidPrice,
|
||||||
|
Bid: bid,
|
||||||
|
Ask: ask,
|
||||||
|
LastPrice: last ?? cleanMidPrice,
|
||||||
|
SpreadPercent: spreadPercent,
|
||||||
|
IsSpreadVolatile: isSpreadVolatile,
|
||||||
|
TimestampUtc: utcTimestamp
|
||||||
|
);
|
||||||
|
|
||||||
|
if (OnTickReceived != null)
|
||||||
|
{
|
||||||
|
await OnTickReceived.Invoke(cleanTick);
|
||||||
|
}
|
||||||
|
|
||||||
|
return cleanTick;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static TimeZoneInfo GetBerlinTimeZone()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return TimeZoneInfo.FindSystemTimeZoneById("W. Europe Standard Time"); // Windows ID
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return TimeZoneInfo.FindSystemTimeZoneById("Europe/Berlin"); // Linux IANA ID
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return TimeZoneInfo.Utc;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,213 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Net.Http;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using FinlyticCore.Dtos.TechnicalAnalysis;
|
||||||
|
using FinlyticCore.Services;
|
||||||
|
using FinlyticCore.Services.Yahoo;
|
||||||
|
using FinlyticTechnicals.Util;
|
||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
|
|
||||||
|
namespace FinlyticTechnicals.Services;
|
||||||
|
|
||||||
|
public record YahooCandlesResult(
|
||||||
|
List<CandleDto> Candles,
|
||||||
|
string Currency
|
||||||
|
);
|
||||||
|
|
||||||
|
public interface IYahooMarketDataScraper
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Resolves ticker from ISIN using Yahoo Search API.
|
||||||
|
/// </summary>
|
||||||
|
Task<string?> ResolveTickerFromIsinAsync(string isin, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Fetches historical candles with strict UTC timestamps.
|
||||||
|
/// </summary>
|
||||||
|
Task<List<CandleDto>> FetchHistoricalCandlesAsync(string symbol, string range = "1y", string interval = "1d", CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Fetches historical candles with currency metadata.
|
||||||
|
/// </summary>
|
||||||
|
Task<YahooCandlesResult> FetchHistoricalCandlesWithCurrencyAsync(string symbol, string range = "1y", string interval = "1d", CancellationToken cancellationToken = default);
|
||||||
|
}
|
||||||
|
|
||||||
|
public class YahooMarketDataScraper : IYahooMarketDataScraper
|
||||||
|
{
|
||||||
|
private readonly YahooFinanceClient _yahooClient;
|
||||||
|
private readonly IConfiguration _configuration;
|
||||||
|
private readonly IFinlyticLogger<YahooMarketDataScraper> _finlyticLogger;
|
||||||
|
|
||||||
|
public YahooMarketDataScraper(
|
||||||
|
YahooFinanceClient yahooClient,
|
||||||
|
IConfiguration configuration,
|
||||||
|
IFinlyticLogger<YahooMarketDataScraper> finlyticLogger)
|
||||||
|
{
|
||||||
|
_yahooClient = yahooClient;
|
||||||
|
_configuration = configuration;
|
||||||
|
_finlyticLogger = finlyticLogger;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<string?> ResolveTickerFromIsinAsync(string isin, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(isin)) return null;
|
||||||
|
|
||||||
|
var cleanIsin = isin.Trim().ToUpperInvariant();
|
||||||
|
if (cleanIsin.Contains('.'))
|
||||||
|
{
|
||||||
|
return cleanIsin;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cleanIsin.StartsWith("X", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
var (cryptoSubtitle, cryptoName) = await FinlyticCore.Util.CryptoSubtitleResolver.ResolveCryptoInfoAsync(
|
||||||
|
cleanIsin, _configuration.GetConnectionString("DefaultConnection"), cancellationToken);
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(cryptoSubtitle))
|
||||||
|
{
|
||||||
|
var candidates = new[] { $"{cryptoSubtitle}-EUR", $"{cryptoSubtitle}-USD", cryptoSubtitle };
|
||||||
|
foreach (var candidate in candidates)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var res = await FetchHistoricalCandlesWithCurrencyAsync(candidate, "5d", "1d", cancellationToken);
|
||||||
|
if (res.Candles.Count > 0)
|
||||||
|
{
|
||||||
|
await _finlyticLogger.LogInfoAsync(SettingKeys.TechnicalAnalysisChannel, "[YahooMarketDataScraper] Resolved Crypto ISIN {Isin} to {Symbol} using Subtitle {Sub}", cleanIsin, candidate, cryptoSubtitle);
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch { }
|
||||||
|
}
|
||||||
|
|
||||||
|
return $"{cryptoSubtitle}-EUR";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var searchResult = await _yahooClient.SearchAsync(cleanIsin, quotesCount: 10, newsCount: 0, cancellationToken);
|
||||||
|
if (searchResult?.Quotes != null && searchResult.Quotes.Count > 0)
|
||||||
|
{
|
||||||
|
var prioritizedSuffixes = new[] { ".DE", ".F", ".SG", ".MU", ".BE", ".DU", ".HM" };
|
||||||
|
|
||||||
|
foreach (var suffix in prioritizedSuffixes)
|
||||||
|
{
|
||||||
|
var match = searchResult.Quotes.FirstOrDefault(q =>
|
||||||
|
!string.IsNullOrWhiteSpace(q.Symbol) &&
|
||||||
|
q.Symbol.EndsWith(suffix, StringComparison.OrdinalIgnoreCase));
|
||||||
|
|
||||||
|
if (match != null)
|
||||||
|
{
|
||||||
|
await _finlyticLogger.LogInfoAsync(SettingKeys.TechnicalAnalysisChannel, "[YahooMarketDataScraper] Resolved ISIN {Isin} to German ticker {Symbol}", cleanIsin, match.Symbol);
|
||||||
|
return match.Symbol;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var defaultQuote = searchResult.Quotes.FirstOrDefault(q => !string.IsNullOrWhiteSpace(q.Symbol));
|
||||||
|
if (defaultQuote != null)
|
||||||
|
{
|
||||||
|
await _finlyticLogger.LogInfoAsync(SettingKeys.TechnicalAnalysisChannel, "[YahooMarketDataScraper] Resolved ISIN {Isin} to primary ticker {Symbol}", cleanIsin, defaultQuote.Symbol);
|
||||||
|
return defaultQuote.Symbol;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
await _finlyticLogger.LogWarningAsync(SettingKeys.TechnicalAnalysisChannel, ex, "[YahooMarketDataScraper] Search failed for ISIN {Isin}", cleanIsin);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<List<CandleDto>> FetchHistoricalCandlesAsync(string symbol, string range = "1y", string interval = "1d", CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var result = await FetchHistoricalCandlesWithCurrencyAsync(symbol, range, interval, cancellationToken);
|
||||||
|
return result.Candles;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<YahooCandlesResult> FetchHistoricalCandlesWithCurrencyAsync(string symbol, string range = "1y", string interval = "1d", CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var results = new List<CandleDto>();
|
||||||
|
string detectedCurrency = FallbackCurrencyBySymbol(symbol);
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(symbol)) return new YahooCandlesResult(results, detectedCurrency);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var chartDto = await _yahooClient.GetChartAsync(symbol, range, interval, cancellationToken);
|
||||||
|
var resultObj = chartDto?.Chart?.Result?.FirstOrDefault();
|
||||||
|
|
||||||
|
if (resultObj == null)
|
||||||
|
{
|
||||||
|
await _finlyticLogger.LogWarningAsync(SettingKeys.TechnicalAnalysisChannel, "[YahooMarketDataScraper] No chart data returned from Yahoo Client for symbol {Symbol}", symbol);
|
||||||
|
return new YahooCandlesResult(results, detectedCurrency);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(resultObj.Meta?.Currency))
|
||||||
|
{
|
||||||
|
detectedCurrency = resultObj.Meta.Currency.ToUpperInvariant();
|
||||||
|
}
|
||||||
|
|
||||||
|
var timestamps = resultObj.Timestamp;
|
||||||
|
var quote = resultObj.Indicators?.Quote?.FirstOrDefault();
|
||||||
|
|
||||||
|
if (timestamps == null || quote == null || timestamps.Count == 0)
|
||||||
|
{
|
||||||
|
return new YahooCandlesResult(results, detectedCurrency);
|
||||||
|
}
|
||||||
|
|
||||||
|
var opens = quote.Open ?? [];
|
||||||
|
var highs = quote.High ?? [];
|
||||||
|
var lows = quote.Low ?? [];
|
||||||
|
var closes = quote.Close ?? [];
|
||||||
|
var volumes = quote.Volume ?? [];
|
||||||
|
|
||||||
|
for (int i = 0; i < timestamps.Count; i++)
|
||||||
|
{
|
||||||
|
// Strict UTC timestamp
|
||||||
|
var dt = DateTimeOffset.FromUnixTimeSeconds(timestamps[i]).UtcDateTime;
|
||||||
|
|
||||||
|
var open = i < opens.Count && opens[i].HasValue ? (decimal)opens[i]!.Value : 0m;
|
||||||
|
var high = i < highs.Count && highs[i].HasValue ? (decimal)highs[i]!.Value : open;
|
||||||
|
var low = i < lows.Count && lows[i].HasValue ? (decimal)lows[i]!.Value : open;
|
||||||
|
var close = i < closes.Count && closes[i].HasValue ? (decimal)closes[i]!.Value : open;
|
||||||
|
var vol = i < volumes.Count && volumes[i].HasValue ? (long)volumes[i]!.Value : 0L;
|
||||||
|
|
||||||
|
if (close <= 0m && open <= 0m) continue;
|
||||||
|
|
||||||
|
results.Add(new CandleDto(
|
||||||
|
Timestamp: dt,
|
||||||
|
Open: open,
|
||||||
|
High: Math.Max(high, Math.Max(open, close)),
|
||||||
|
Low: Math.Min(low, Math.Min(open, close)),
|
||||||
|
Close: close,
|
||||||
|
Volume: vol
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
await _finlyticLogger.LogInfoAsync(SettingKeys.TechnicalAnalysisChannel, "[YahooMarketDataScraper] Successfully fetched {Count} candles for {Symbol} ({Range}, {Interval}, Currency: {Currency})",
|
||||||
|
results.Count, symbol, range, interval, detectedCurrency);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
await _finlyticLogger.LogErrorAsync(SettingKeys.TechnicalAnalysisChannel, ex, "[YahooMarketDataScraper] Error fetching historical candles for {Symbol}", symbol);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new YahooCandlesResult(results, detectedCurrency);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string FallbackCurrencyBySymbol(string symbol)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(symbol)) return "EUR";
|
||||||
|
var s = symbol.Trim().ToUpperInvariant();
|
||||||
|
if (s.EndsWith(".DE") || s.EndsWith(".F") || s.EndsWith(".PA") || s.EndsWith(".AS") || s.EndsWith(".MI"))
|
||||||
|
return "EUR";
|
||||||
|
if (s.EndsWith(".L"))
|
||||||
|
return "GBp";
|
||||||
|
return "USD";
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,24 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
using FinlyticCore.Dtos.TechnicalAnalysis;
|
||||||
|
|
||||||
|
namespace FinlyticTechnicals.Strategies;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Strategy contract for evaluating technical context, indicators, and detected patterns to produce structured trading setups with an ExitPlan.
|
||||||
|
/// </summary>
|
||||||
|
public interface ITechnicalStrategy
|
||||||
|
{
|
||||||
|
string StrategyKey { get; }
|
||||||
|
string StrategyName { get; }
|
||||||
|
int Priority { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Checks if this strategy is applicable in the current market regime.
|
||||||
|
/// </summary>
|
||||||
|
bool IsApplicable(MarketRegime regime);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Evaluates technical context and active patterns to generate a StrategyResultDto or null.
|
||||||
|
/// </summary>
|
||||||
|
StrategyResultDto? Evaluate(TechnicalContext context, IReadOnlyList<PatternResultDto> activePatterns);
|
||||||
|
}
|
||||||
@@ -0,0 +1,228 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Threading;
|
||||||
|
|
||||||
|
namespace FinlyticTechnicals.Timeframe;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Thread-safe, high-performance circular ring buffer with zero allocations on updates.
|
||||||
|
/// Holds a fixed capacity of items (e.g. 500 candles).
|
||||||
|
/// </summary>
|
||||||
|
/// <typeparam name="T">The item type (e.g. CandleDto)</typeparam>
|
||||||
|
public class CircularRingBuffer<T> : IReadOnlyList<T>
|
||||||
|
{
|
||||||
|
private readonly T[] _buffer;
|
||||||
|
private readonly int _capacity;
|
||||||
|
private int _start;
|
||||||
|
private int _count;
|
||||||
|
private readonly ReaderWriterLockSlim _lock = new(LockRecursionPolicy.NoRecursion);
|
||||||
|
|
||||||
|
public CircularRingBuffer(int capacity = 500)
|
||||||
|
{
|
||||||
|
if (capacity <= 0) throw new ArgumentOutOfRangeException(nameof(capacity), "Capacity must be positive.");
|
||||||
|
_capacity = capacity;
|
||||||
|
_buffer = new T[capacity];
|
||||||
|
_start = 0;
|
||||||
|
_count = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int Capacity => _capacity;
|
||||||
|
|
||||||
|
public int Count
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
_lock.EnterReadLock();
|
||||||
|
try { return _count; }
|
||||||
|
finally { _lock.ExitReadLock(); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Adds an item to the buffer. If capacity is reached, the oldest element is overwritten in O(1).
|
||||||
|
/// </summary>
|
||||||
|
public void Add(T item)
|
||||||
|
{
|
||||||
|
_lock.EnterWriteLock();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (_count < _capacity)
|
||||||
|
{
|
||||||
|
int nextIndex = (_start + _count) % _capacity;
|
||||||
|
_buffer[nextIndex] = item;
|
||||||
|
_count++;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_buffer[_start] = item;
|
||||||
|
_start = (_start + 1) % _capacity;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_lock.ExitWriteLock();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Updates the last (most recent) item in place.
|
||||||
|
/// </summary>
|
||||||
|
public void UpdateLast(T item)
|
||||||
|
{
|
||||||
|
_lock.EnterWriteLock();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (_count == 0)
|
||||||
|
{
|
||||||
|
_buffer[_start] = item;
|
||||||
|
_count = 1;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
int lastIndex = (_start + _count - 1) % _capacity;
|
||||||
|
_buffer[lastIndex] = item;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_lock.ExitWriteLock();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the most recent item or default if empty.
|
||||||
|
/// </summary>
|
||||||
|
public T? GetLast()
|
||||||
|
{
|
||||||
|
_lock.EnterReadLock();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (_count == 0) return default;
|
||||||
|
int lastIndex = (_start + _count - 1) % _capacity;
|
||||||
|
return _buffer[lastIndex];
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_lock.ExitReadLock();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Indexer accessing items from oldest (0) to newest (Count - 1).
|
||||||
|
/// </summary>
|
||||||
|
public T this[int index]
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
_lock.EnterReadLock();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (index < 0 || index >= _count)
|
||||||
|
throw new ArgumentOutOfRangeException(nameof(index), "Index out of range.");
|
||||||
|
|
||||||
|
int actualIndex = (_start + index) % _capacity;
|
||||||
|
return _buffer[actualIndex];
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_lock.ExitReadLock();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns an ordered immutable array snapshot of all elements.
|
||||||
|
/// </summary>
|
||||||
|
public T[] ToArray()
|
||||||
|
{
|
||||||
|
_lock.EnterReadLock();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (_count == 0) return Array.Empty<T>();
|
||||||
|
var result = new T[_count];
|
||||||
|
for (int i = 0; i < _count; i++)
|
||||||
|
{
|
||||||
|
int actualIndex = (_start + i) % _capacity;
|
||||||
|
result[i] = _buffer[actualIndex];
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_lock.ExitReadLock();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Populates the buffer in bulk with historical data (oldest to newest).
|
||||||
|
/// </summary>
|
||||||
|
public void LoadBulk(IEnumerable<T> items)
|
||||||
|
{
|
||||||
|
_lock.EnterWriteLock();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_start = 0;
|
||||||
|
_count = 0;
|
||||||
|
foreach (var item in items)
|
||||||
|
{
|
||||||
|
if (_count < _capacity)
|
||||||
|
{
|
||||||
|
_buffer[_count] = item;
|
||||||
|
_count++;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_buffer[_start] = item;
|
||||||
|
_start = (_start + 1) % _capacity;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_lock.ExitWriteLock();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public Enumerator GetEnumerator() => new(this);
|
||||||
|
IEnumerator<T> IEnumerable<T>.GetEnumerator() => new Enumerator(this);
|
||||||
|
IEnumerator IEnumerable.GetEnumerator() => new Enumerator(this);
|
||||||
|
|
||||||
|
public struct Enumerator : IEnumerator<T>
|
||||||
|
{
|
||||||
|
private readonly CircularRingBuffer<T> _buffer;
|
||||||
|
private int _index;
|
||||||
|
private T? _current;
|
||||||
|
|
||||||
|
internal Enumerator(CircularRingBuffer<T> buffer)
|
||||||
|
{
|
||||||
|
_buffer = buffer;
|
||||||
|
_index = 0;
|
||||||
|
_current = default;
|
||||||
|
}
|
||||||
|
|
||||||
|
public readonly T Current => _current!;
|
||||||
|
readonly object? IEnumerator.Current => Current;
|
||||||
|
|
||||||
|
public bool MoveNext()
|
||||||
|
{
|
||||||
|
if (_index < _buffer.Count)
|
||||||
|
{
|
||||||
|
_current = _buffer[_index];
|
||||||
|
_index++;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
_current = default;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Reset()
|
||||||
|
{
|
||||||
|
_index = 0;
|
||||||
|
_current = default;
|
||||||
|
}
|
||||||
|
|
||||||
|
public readonly void Dispose() { }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
using FinlyticCore.Models.Settings;
|
||||||
|
|
||||||
|
namespace FinlyticTechnicals.Util;
|
||||||
|
|
||||||
|
public static class SettingKeys
|
||||||
|
{
|
||||||
|
// --- Logging-Kanäle ---
|
||||||
|
public static readonly SettingKey<bool> TechnicalAnalysisChannel = new("Logging.Channel.TechnicalAnalysis", true);
|
||||||
|
public static readonly SettingKey<bool> MqttChannel = new("Logging.Channel.MQTT", true);
|
||||||
|
public static readonly SettingKey<bool> HealthPingChannel = new("Logging.Channel.Health", true);
|
||||||
|
|
||||||
|
// --- Indikator-Konfiguration ---
|
||||||
|
public static readonly SettingKey<int> RsiPeriod = new("Indicators.RsiPeriod", 14);
|
||||||
|
public static readonly SettingKey<int> MacdFastPeriod = new("Indicators.MacdFastPeriod", 12);
|
||||||
|
public static readonly SettingKey<int> MacdSlowPeriod = new("Indicators.MacdSlowPeriod", 26);
|
||||||
|
public static readonly SettingKey<int> MacdSignalPeriod = new("Indicators.MacdSignalPeriod", 9);
|
||||||
|
public static readonly SettingKey<int> EmaShortPeriod = new("Indicators.EmaShortPeriod", 50);
|
||||||
|
public static readonly SettingKey<int> EmaLongPeriod = new("Indicators.EmaLongPeriod", 200);
|
||||||
|
public static readonly SettingKey<int> BollingerBandsPeriod = new("Indicators.BollingerBandsPeriod", 20);
|
||||||
|
public static readonly SettingKey<double> BollingerBandsStdDev = new("Indicators.BollingerBandsStdDev", 2.0);
|
||||||
|
public static readonly SettingKey<int> AtrPeriod = new("Indicators.AtrPeriod", 14);
|
||||||
|
|
||||||
|
// --- Cache & Performance ---
|
||||||
|
public static readonly SettingKey<int> CacheDurationMinutes = new("Cache.DurationMinutes", 60);
|
||||||
|
public static readonly SettingKey<bool> EnableAutoCache = new("Feature.EnableAutoCache", true);
|
||||||
|
}
|
||||||
@@ -0,0 +1,271 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using FinlyticCore.Dtos;
|
||||||
|
using FinlyticCore.Dtos.Settings;
|
||||||
|
using FinlyticCore.Dtos.TechnicalAnalysis;
|
||||||
|
using FinlyticCore.Models;
|
||||||
|
using FinlyticCore.Services;
|
||||||
|
using FinlyticCore.Util;
|
||||||
|
using FinlyticTechnicals.Services;
|
||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.Hosting;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace FinlyticTechnicals.Util;
|
||||||
|
|
||||||
|
public record GetSetupsRequest(
|
||||||
|
bool TopPicksOnly = false,
|
||||||
|
int Limit = 50,
|
||||||
|
decimal? MinScore = null
|
||||||
|
);
|
||||||
|
|
||||||
|
public record GetCandlesRequest(
|
||||||
|
string Isin = "",
|
||||||
|
string Timeframe = "15m"
|
||||||
|
);
|
||||||
|
|
||||||
|
public class TAMqttClient : ManagedMqttClient, IHostedService, ITAMqttRpcClient
|
||||||
|
{
|
||||||
|
private readonly IConfiguration _configuration;
|
||||||
|
private readonly IServiceScopeFactory _scopeFactory;
|
||||||
|
private readonly ILogger<TAMqttClient> _logger;
|
||||||
|
|
||||||
|
public TAMqttClient(
|
||||||
|
ILogger<TAMqttClient> logger,
|
||||||
|
IConfiguration configuration,
|
||||||
|
IServiceScopeFactory scopeFactory) : base(logger)
|
||||||
|
{
|
||||||
|
_logger = logger;
|
||||||
|
_configuration = configuration;
|
||||||
|
_scopeFactory = scopeFactory;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task StartAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var config = MqttConfiguration.FromConfiguration(_configuration, "FinlyticTechnicals");
|
||||||
|
|
||||||
|
_logger.LogInformation("Starting Technical Analysis MQTT client. Host: {Host}, ClientId: {ClientId}", config.Host, config.ClientId);
|
||||||
|
await ConnectAsync(config);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task StopAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("Stopping Technical Analysis MQTT client.");
|
||||||
|
await DisconnectAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override async Task OnConnectedAsync()
|
||||||
|
{
|
||||||
|
_logger.LogInformation("Technical Analysis MQTT client connected. Registering RPC endpoints...");
|
||||||
|
|
||||||
|
await SubscribeAsync(MqttTopics.ResponseWildcard);
|
||||||
|
await SubscribeRpcAsync<IsinRequest, TechnicalAnalysisDto?>(MqttTopics.RequestFilter(MqttTopics.Channels.TaGetAnalysis), HandleGetAnalysisRpcAsync);
|
||||||
|
await SubscribeRpcAsync<IsinRequest, List<StrategyResultDto>>(MqttTopics.RequestFilter(MqttTopics.Channels.TaGetSetupsForIsin), HandleGetSetupsForIsinRpcAsync);
|
||||||
|
await SubscribeRpcAsync<GetSetupsRequest, List<StrategyResultDto>>(MqttTopics.RequestFilter(MqttTopics.Channels.TaGetSetups), HandleGetSetupsRpcAsync);
|
||||||
|
await SubscribeRpcAsync<GetCandlesRequest, IReadOnlyList<CandleDto>>(MqttTopics.RequestFilter(MqttTopics.Channels.TaGetCandles), HandleGetCandlesRpcAsync);
|
||||||
|
await SubscribeRpcAsync<object, List<WatchlistEntryDto>>(MqttTopics.RequestFilter(MqttTopics.Channels.TaGetWatchlist), HandleGetWatchlistRpcAsync);
|
||||||
|
await SubscribeRpcAsync<GetRecentSetupHistoryRequest, List<StrategyResultDto>>(MqttTopics.RequestFilter(MqttTopics.Channels.TaGetRecentSetupHistory), HandleGetRecentSetupHistoryRpcAsync);
|
||||||
|
await SubscribeRpcAsync<object, List<DynamicSettingDto>>(MqttTopics.RequestFilter(MqttTopics.Channels.TaSettingsGetAll), HandleSettingsGetAllRpcAsync);
|
||||||
|
await SubscribeRpcAsync<Dictionary<string, object?>, List<DynamicSettingDto>>(MqttTopics.RequestFilter(MqttTopics.Channels.TaSettingsUpdate), HandleSettingsUpdateRpcAsync);
|
||||||
|
await SubscribeAsync<object>(MqttTopics.RequestFilter(MqttTopics.Channels.HealthPing), HandleHealthPingRpcAsync);
|
||||||
|
|
||||||
|
// Subscribe to Sentiment Spikes & Stream
|
||||||
|
await SubscribeAsync(MqttTopics.SentimentWildcard);
|
||||||
|
|
||||||
|
FinlyticCore.Services.FinlyticLogBroadcaster.OnLogPublished = async (logDto) =>
|
||||||
|
{
|
||||||
|
if (IsConnected && (string.Equals(logDto.ServiceName, "FinlyticTechnicals", StringComparison.OrdinalIgnoreCase) || string.Equals(logDto.ServiceName, "FinlyticTechnicalAnalysis", StringComparison.OrdinalIgnoreCase)))
|
||||||
|
{
|
||||||
|
await PublishAsync(MqttTopics.Logs("FinlyticTechnicals"), logDto);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override async Task OnMessageReceivedAsync(string topic, string payloadStr)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(topic) || string.IsNullOrWhiteSpace(payloadStr)) return;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (topic.StartsWith(MqttTopics.SentimentPrefix, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
await HandleSentimentEventAsync(topic, payloadStr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "[TAMqttClient] Error handling message on topic {Topic}", topic);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task HandleSentimentEventAsync(string topic, string payloadStr)
|
||||||
|
{
|
||||||
|
using var jsonDoc = JsonDocument.Parse(payloadStr);
|
||||||
|
var root = jsonDoc.RootElement;
|
||||||
|
|
||||||
|
string? isin = root.TryGetProperty("isin", out var isinProp) ? isinProp.GetString() : null;
|
||||||
|
if (string.IsNullOrWhiteSpace(isin))
|
||||||
|
{
|
||||||
|
// Try extracting from topic if stream format: finlytic/sentiment/stream/{isin}
|
||||||
|
var parts = topic.Split('/');
|
||||||
|
if (parts.Length >= 4 && parts[2].Equals("stream", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
isin = parts[3];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(isin)) return;
|
||||||
|
|
||||||
|
double compoundScore = 0.0;
|
||||||
|
string? trend = null;
|
||||||
|
|
||||||
|
if (root.TryGetProperty("currentSummary", out var currSummary))
|
||||||
|
{
|
||||||
|
if (currSummary.TryGetProperty("compoundScore", out var csProp)) compoundScore = csProp.GetDouble();
|
||||||
|
if (currSummary.TryGetProperty("trend", out var trProp)) trend = trProp.GetString();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
if (root.TryGetProperty("compoundScore", out var csProp)) compoundScore = csProp.GetDouble();
|
||||||
|
if (root.TryGetProperty("trend", out var trProp)) trend = trProp.GetString();
|
||||||
|
}
|
||||||
|
|
||||||
|
bool isSpike = Math.Abs(compoundScore) >= 0.5 ||
|
||||||
|
string.Equals(trend, "IMPROVING", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
string.Equals(trend, "DETERIORATING", StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
if (isSpike)
|
||||||
|
{
|
||||||
|
using var scope = _scopeFactory.CreateScope();
|
||||||
|
var universeManager = scope.ServiceProvider.GetRequiredService<ITechnicalUniverseManager>();
|
||||||
|
var logger = scope.ServiceProvider.GetRequiredService<IFinlyticLogger<TAMqttClient>>();
|
||||||
|
|
||||||
|
await universeManager.AddOrUpdateAssetAsync(isin, null, UniverseSource.SentimentSpike, priority: 1, ttl: TimeSpan.FromMinutes(120));
|
||||||
|
|
||||||
|
await logger.LogInfoAsync(SettingKeys.TechnicalAnalysisChannel,
|
||||||
|
"[TAMqttClient] Sentiment Spike event detected on {Topic} for ISIN {Isin} (Compound={Score:F2}, Trend={Trend}) -> Promoted to Priority 1 (TTL 120m)",
|
||||||
|
topic, isin, compoundScore, trend ?? "N/A");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
private async Task<TechnicalAnalysisDto?> HandleGetAnalysisRpcAsync(IsinRequest? req, string correlationId)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(req?.Isin)) return null;
|
||||||
|
|
||||||
|
using var scope = _scopeFactory.CreateScope();
|
||||||
|
var logger = scope.ServiceProvider.GetRequiredService<IFinlyticLogger<TAMqttClient>>();
|
||||||
|
var scoringEngine = scope.ServiceProvider.GetRequiredService<ITechnicalScoringEngine>();
|
||||||
|
|
||||||
|
await logger.LogInfoAsync(SettingKeys.TechnicalAnalysisChannel, "[TAMqttClient] Processing RPC ta_GetAnalysis for ISIN {Isin} [CorrelationId: {CorrelationId}]", req.Isin, correlationId);
|
||||||
|
return await scoringEngine.GetTechnicalAnalysisDtoAsync(req.Isin, req.Ticker);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<List<StrategyResultDto>> HandleGetSetupsForIsinRpcAsync(IsinRequest? req, string correlationId)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(req?.Isin)) return [];
|
||||||
|
|
||||||
|
using var scope = _scopeFactory.CreateScope();
|
||||||
|
var logger = scope.ServiceProvider.GetRequiredService<IFinlyticLogger<TAMqttClient>>();
|
||||||
|
var scoringEngine = scope.ServiceProvider.GetRequiredService<ITechnicalScoringEngine>();
|
||||||
|
var universeManager = scope.ServiceProvider.GetRequiredService<ITechnicalUniverseManager>();
|
||||||
|
|
||||||
|
await logger.LogInfoAsync(SettingKeys.TechnicalAnalysisChannel, "[TAMqttClient] Processing RPC ta_GetSetupsForIsin for ISIN {Isin} [CorrelationId: {CorrelationId}]", req.Isin, correlationId);
|
||||||
|
|
||||||
|
// Attach the current universe-selection reason (if the ISIN is actively monitored) so a caller (e.g.
|
||||||
|
// FinlyticEngine's TradeLifecycleService) can record WHY this asset was being watched, not just its
|
||||||
|
// scores. Stays null for an ISIN nobody favorited/discovered/spiked - an honest ad hoc analysis.
|
||||||
|
var universeEntry = await universeManager.GetEntryAsync(req.Isin);
|
||||||
|
return await scoringEngine.AnalyzeIsinAsync(req.Isin, req.Ticker, universeEntry?.Source, universeEntry?.AddedAtUtc);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<List<StrategyResultDto>> HandleGetSetupsRpcAsync(GetSetupsRequest? req, string correlationId)
|
||||||
|
{
|
||||||
|
using var scope = _scopeFactory.CreateScope();
|
||||||
|
var logger = scope.ServiceProvider.GetRequiredService<IFinlyticLogger<TAMqttClient>>();
|
||||||
|
var scoringEngine = scope.ServiceProvider.GetRequiredService<ITechnicalScoringEngine>();
|
||||||
|
|
||||||
|
await logger.LogInfoAsync(SettingKeys.TechnicalAnalysisChannel, "[TAMqttClient] Processing RPC ta_GetSetups (TopPicks: {TopPicks}, Limit: {Limit}, MinScore: {MinScore}) [CorrelationId: {CorrelationId}]", req?.TopPicksOnly ?? false, req?.Limit ?? 50, req?.MinScore?.ToString() ?? "null", correlationId);
|
||||||
|
return await scoringEngine.GetActiveSetupsAsync(req?.TopPicksOnly ?? false, req?.Limit ?? 50, req?.MinScore);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<List<WatchlistEntryDto>> HandleGetWatchlistRpcAsync(object? req, string correlationId)
|
||||||
|
{
|
||||||
|
using var scope = _scopeFactory.CreateScope();
|
||||||
|
var logger = scope.ServiceProvider.GetRequiredService<IFinlyticLogger<TAMqttClient>>();
|
||||||
|
var universeManager = scope.ServiceProvider.GetRequiredService<ITechnicalUniverseManager>();
|
||||||
|
|
||||||
|
await logger.LogInfoAsync(SettingKeys.TechnicalAnalysisChannel, "[TAMqttClient] Processing RPC ta_GetWatchlist [CorrelationId: {CorrelationId}]", correlationId);
|
||||||
|
|
||||||
|
var universe = await universeManager.GetActiveUniverseAsync();
|
||||||
|
return universe.Select(e => new WatchlistEntryDto(e.Isin, e.Symbol, e.Source.ToString(), e.Priority, e.AddedAtUtc, e.ExpiresAtUtc)).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<List<StrategyResultDto>> HandleGetRecentSetupHistoryRpcAsync(GetRecentSetupHistoryRequest? req, string correlationId)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(req?.Isin)) return [];
|
||||||
|
|
||||||
|
using var scope = _scopeFactory.CreateScope();
|
||||||
|
var logger = scope.ServiceProvider.GetRequiredService<IFinlyticLogger<TAMqttClient>>();
|
||||||
|
var scoringEngine = scope.ServiceProvider.GetRequiredService<ITechnicalScoringEngine>();
|
||||||
|
|
||||||
|
await logger.LogInfoAsync(SettingKeys.TechnicalAnalysisChannel, "[TAMqttClient] Processing RPC ta_GetRecentSetupHistory for ISIN {Isin} [CorrelationId: {CorrelationId}]", req.Isin, correlationId);
|
||||||
|
|
||||||
|
return await scoringEngine.GetRecentSetupHistoryAsync(req.Isin, req.Limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Task<IReadOnlyList<CandleDto>> HandleGetCandlesRpcAsync(GetCandlesRequest? req, string correlationId)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(req?.Isin)) return Task.FromResult<IReadOnlyList<CandleDto>>([]);
|
||||||
|
|
||||||
|
using var scope = _scopeFactory.CreateScope();
|
||||||
|
var aggregator = scope.ServiceProvider.GetRequiredService<IMultiTimeframeCandleAggregator>();
|
||||||
|
|
||||||
|
var candles = aggregator.GetCandles(req.Isin, req.Timeframe ?? "15m");
|
||||||
|
return Task.FromResult(candles);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<List<DynamicSettingDto>> HandleSettingsGetAllRpcAsync(object? _, string correlationId)
|
||||||
|
{
|
||||||
|
using var scope = _scopeFactory.CreateScope();
|
||||||
|
var logger = scope.ServiceProvider.GetRequiredService<IFinlyticLogger<TAMqttClient>>();
|
||||||
|
var settingsService = scope.ServiceProvider.GetRequiredService<ISettingsService>();
|
||||||
|
|
||||||
|
await logger.LogInfoAsync(SettingKeys.MqttChannel, "[FinlyticTechnicals] [Settings_GetAll] Retrieving dynamic settings [CorrelationId: {CorrelationId}]", correlationId);
|
||||||
|
return await settingsService.GetAllRegisteredSettingsAsync(new[] { typeof(SettingKeys) });
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<List<DynamicSettingDto>> HandleSettingsUpdateRpcAsync(Dictionary<string, object?>? updates, string correlationId)
|
||||||
|
{
|
||||||
|
using var scope = _scopeFactory.CreateScope();
|
||||||
|
var logger = scope.ServiceProvider.GetRequiredService<IFinlyticLogger<TAMqttClient>>();
|
||||||
|
var settingsService = scope.ServiceProvider.GetRequiredService<ISettingsService>();
|
||||||
|
|
||||||
|
await logger.LogInfoAsync(SettingKeys.MqttChannel, "[FinlyticTechnicals] [Settings_Update] Processing settings update [CorrelationId: {CorrelationId}]", correlationId);
|
||||||
|
if (updates != null && updates.Count > 0)
|
||||||
|
{
|
||||||
|
await settingsService.UpdateSettingsAsync(updates);
|
||||||
|
await logger.LogInfoAsync(SettingKeys.MqttChannel, "[FinlyticTechnicals] Successfully updated {Count} settings in database and cache.", updates.Count);
|
||||||
|
}
|
||||||
|
return await settingsService.GetAllRegisteredSettingsAsync(new[] { typeof(SettingKeys) });
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task HandleHealthPingRpcAsync(object? _, string topic, string correlationId)
|
||||||
|
{
|
||||||
|
if (topic.Contains("FinlyticTechnicals", StringComparison.OrdinalIgnoreCase) || topic.Contains("FinlyticTechnicalAnalysis", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
string respTopic = MqttTopics.ResponseTopic(MqttTopics.Channels.HealthPing, correlationId);
|
||||||
|
await PublishAsync(respTopic, new ServiceHealthResponse("FinlyticTechnicals", "Online", DateTime.UtcNow, "Connected"));
|
||||||
|
|
||||||
|
using var scope = _scopeFactory.CreateScope();
|
||||||
|
var logger = scope.ServiceProvider.GetRequiredService<IFinlyticLogger<TAMqttClient>>();
|
||||||
|
await logger.LogInfoAsync(SettingKeys.HealthPingChannel, "[FinlyticTechnicals] Responded to live health_Ping RPC [CorrelationId: {CorrelationId}].", correlationId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"Logging": {
|
||||||
|
"LogLevel": {
|
||||||
|
"Default": "Information",
|
||||||
|
"Microsoft.Hosting.Lifetime": "Information",
|
||||||
|
"Microsoft.EntityFrameworkCore": "Warning",
|
||||||
|
"Microsoft.EntityFrameworkCore.Database.Command": "Warning",
|
||||||
|
"FinlyticCore.Services.TradeRepublic.TradeRepublicClient": "Debug"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ConnectionStrings": {
|
||||||
|
"DefaultConnection": "Host=localhost;Database=finlytic_ta;Username=admin;Password=admin"
|
||||||
|
},
|
||||||
|
"MQTT": {
|
||||||
|
"Host": "localhost",
|
||||||
|
"Port": "4545",
|
||||||
|
"ClientId": "finlytic_ta"
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user