feat(technicals): add technical analysis microservice with indicator engines, pattern detectors, and strategies
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user