246 lines
9.6 KiB
C#
246 lines
9.6 KiB
C#
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;
|
|
}
|
|
}
|