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