feat(technicals): add technical analysis microservice with indicator engines, pattern detectors, and strategies

This commit is contained in:
2026-08-24 21:36:05 +02:00
parent 12e7b57b16
commit f43ce2b7e9
36 changed files with 6792 additions and 0 deletions
@@ -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);
}
}
+22
View File
@@ -0,0 +1,22 @@
FROM mcr.microsoft.com/dotnet/runtime:10.0 AS base
USER $APP_UID
WORKDIR /app
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
ARG BUILD_CONFIGURATION=Release
WORKDIR /src
COPY ["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");
}
}
}
@@ -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");
}
}
}
@@ -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");
}
}
}
@@ -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;
}
}
+110
View File
@@ -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() { }
}
}
+26
View File
@@ -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);
}
+271
View File
@@ -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);
}
}
}
+19
View File
@@ -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"
}
}