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

This commit is contained in:
2026-08-24 21:36:05 +02:00
parent 12e7b57b16
commit f43ce2b7e9
36 changed files with 6792 additions and 0 deletions
@@ -0,0 +1,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);
}
}