indicator_hub 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +5 -0
  3. data/CODE_OF_CONDUCT.md +10 -0
  4. data/LICENSE.txt +21 -0
  5. data/README.md +551 -0
  6. data/Rakefile +12 -0
  7. data/exe/indicator_hub +4 -0
  8. data/lib/indicator_hub/calculation_helpers.rb +196 -0
  9. data/lib/indicator_hub/indicators/adi.rb +37 -0
  10. data/lib/indicator_hub/indicators/adtv.rb +27 -0
  11. data/lib/indicator_hub/indicators/adx.rb +102 -0
  12. data/lib/indicator_hub/indicators/ao.rb +39 -0
  13. data/lib/indicator_hub/indicators/atr.rb +45 -0
  14. data/lib/indicator_hub/indicators/base_indicator.rb +68 -0
  15. data/lib/indicator_hub/indicators/bb.rb +44 -0
  16. data/lib/indicator_hub/indicators/cci.rb +41 -0
  17. data/lib/indicator_hub/indicators/cmf.rb +54 -0
  18. data/lib/indicator_hub/indicators/cmo.rb +49 -0
  19. data/lib/indicator_hub/indicators/cr.rb +26 -0
  20. data/lib/indicator_hub/indicators/dc.rb +40 -0
  21. data/lib/indicator_hub/indicators/dlr.rb +27 -0
  22. data/lib/indicator_hub/indicators/dpo.rb +32 -0
  23. data/lib/indicator_hub/indicators/dr.rb +27 -0
  24. data/lib/indicator_hub/indicators/ema.rb +40 -0
  25. data/lib/indicator_hub/indicators/envelopes_ema.rb +36 -0
  26. data/lib/indicator_hub/indicators/eom.rb +48 -0
  27. data/lib/indicator_hub/indicators/fi.rb +45 -0
  28. data/lib/indicator_hub/indicators/ichimoku.rb +76 -0
  29. data/lib/indicator_hub/indicators/imi.rb +48 -0
  30. data/lib/indicator_hub/indicators/kc.rb +46 -0
  31. data/lib/indicator_hub/indicators/kst.rb +82 -0
  32. data/lib/indicator_hub/indicators/macd.rb +46 -0
  33. data/lib/indicator_hub/indicators/mfi.rb +62 -0
  34. data/lib/indicator_hub/indicators/mi.rb +81 -0
  35. data/lib/indicator_hub/indicators/nvi.rb +42 -0
  36. data/lib/indicator_hub/indicators/obv.rb +41 -0
  37. data/lib/indicator_hub/indicators/obv_mean.rb +42 -0
  38. data/lib/indicator_hub/indicators/pivot_points.rb +44 -0
  39. data/lib/indicator_hub/indicators/price_channel.rb +38 -0
  40. data/lib/indicator_hub/indicators/qstick.rb +36 -0
  41. data/lib/indicator_hub/indicators/rmi.rb +48 -0
  42. data/lib/indicator_hub/indicators/roc.rb +37 -0
  43. data/lib/indicator_hub/indicators/rsi.rb +67 -0
  44. data/lib/indicator_hub/indicators/sma.rb +32 -0
  45. data/lib/indicator_hub/indicators/so.rb +76 -0
  46. data/lib/indicator_hub/indicators/trix.rb +53 -0
  47. data/lib/indicator_hub/indicators/tsi.rb +67 -0
  48. data/lib/indicator_hub/indicators/uo.rb +67 -0
  49. data/lib/indicator_hub/indicators/vi.rb +54 -0
  50. data/lib/indicator_hub/indicators/volume_oscillator.rb +55 -0
  51. data/lib/indicator_hub/indicators/vpt.rb +35 -0
  52. data/lib/indicator_hub/indicators/vwap.rb +33 -0
  53. data/lib/indicator_hub/indicators/wilders_smoothing.rb +36 -0
  54. data/lib/indicator_hub/indicators/wma.rb +36 -0
  55. data/lib/indicator_hub/indicators/wr.rb +36 -0
  56. data/lib/indicator_hub/series.rb +79 -0
  57. data/lib/indicator_hub/talib_adapter.rb +23 -0
  58. data/lib/indicator_hub/validation.rb +49 -0
  59. data/lib/indicator_hub/version.rb +6 -0
  60. data/lib/indicator_hub.rb +485 -0
  61. data/sig/indicator_hub.rbs +4 -0
  62. metadata +112 -0
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../calculation_helpers"
4
+
5
+ module IndicatorHub
6
+ module Indicators
7
+ # Chande Momentum Oscillator (CMO).
8
+ # CMO is a technical momentum indicator developed by Tushar Chande.
9
+ class CMO
10
+ # Calculates the Chande Momentum Oscillator.
11
+ # @param data [Array<Numeric>] Input data.
12
+ # @param period [Integer] Period for CMO calculation.
13
+ # @return [Array<Float, nil>]
14
+ def self.calculate(data, period: 14)
15
+ output = []
16
+
17
+ data.each_with_index do |_val, i|
18
+ if i < period
19
+ output << nil
20
+ next
21
+ end
22
+
23
+ up_sum = 0.0
24
+ down_sum = 0.0
25
+
26
+ (1..period).each do |j|
27
+ curr = data[i - period + j]
28
+ prev = data[i - period + j - 1]
29
+ diff = curr - prev
30
+ if diff.positive?
31
+ up_sum += diff
32
+ else
33
+ down_sum += diff.abs
34
+ end
35
+ end
36
+
37
+ if (up_sum + down_sum).zero?
38
+ output << 0.0
39
+ else
40
+ cmo = 100.0 * (up_sum - down_sum) / (up_sum + down_sum)
41
+ output << cmo
42
+ end
43
+ end
44
+
45
+ output
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../calculation_helpers"
4
+
5
+ module IndicatorHub
6
+ module Indicators
7
+ # Cumulative Return (CR).
8
+ # CR is a measure of the total return on an investment over a set period of time.
9
+ class CR
10
+ # Calculates the Cumulative Return.
11
+ # @param data [Array<Numeric>] The input data points.
12
+ # @param period [Integer] The period (default: 1).
13
+ # @return [Array<Float>] The calculated CR values.
14
+ def self.calculate(data, period: 1) # rubocop:disable Lint/UnusedMethodArgument
15
+ return [] if data.empty?
16
+
17
+ start_price = data.first.to_f
18
+ return Array.new(data.size, 0.0) if start_price.zero?
19
+
20
+ data.map do |v|
21
+ (v.to_f - start_price) / start_price
22
+ end
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../calculation_helpers"
4
+
5
+ module IndicatorHub
6
+ module Indicators
7
+ # Donchian Channel (DC).
8
+ # DC consists of three lines generated by moving average calculations that comprise
9
+ # an indicator formed by upper and lower bands around a midrange or median band.
10
+ class DC
11
+ # Calculates the Donchian Channel.
12
+ # @param data [Array<Hash>] Array of OHLCV hashes.
13
+ # @param period [Integer] The DC period (default: 20).
14
+ # @return [Array<Hash>] The calculated DC values { upper: Float, middle: Float, lower: Float }.
15
+ def self.calculate(data, period: 20)
16
+ output = []
17
+
18
+ data.each_with_index do |_, i|
19
+ if i < period - 1
20
+ output << { upper: nil, middle: nil, lower: nil }
21
+ else
22
+ period_data = data[(i - period + 1)..i]
23
+ highs = period_data.map { |v| (v[:high] || v["high"]).to_f }
24
+ lows = period_data.map { |v| (v[:low] || v["low"]).to_f }
25
+
26
+ upper = highs.max
27
+ lower = lows.min
28
+
29
+ output << {
30
+ upper: upper,
31
+ middle: (upper + lower) / 2.0,
32
+ lower: lower
33
+ }
34
+ end
35
+ end
36
+ output
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module IndicatorHub
4
+ module Indicators
5
+ # Daily Log Return (DLR).
6
+ # DLR is the logarithmic return of a security's price from one day to the next.
7
+ class DLR
8
+ # Calculates the Daily Log Return.
9
+ # @param data [Array<Numeric>] The input data points.
10
+ # @return [Array<Float, nil>] The calculated DLR values.
11
+ def self.calculate(data)
12
+ output = []
13
+ prev_price = nil
14
+
15
+ data.each do |v|
16
+ output << if prev_price.nil?
17
+ nil
18
+ else
19
+ Math.log(v.to_f / prev_price)
20
+ end
21
+ prev_price = v
22
+ end
23
+ output
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../calculation_helpers"
4
+
5
+ module IndicatorHub
6
+ module Indicators
7
+ # Detrended Price Oscillator (DPO).
8
+ # DPO is an indicator that attempts to eliminate trend from price in order to
9
+ # make it easier to identify cycles.
10
+ class DPO
11
+ # Calculates the Detrended Price Oscillator.
12
+ # @param data [Array<Numeric>] The input data points.
13
+ # @param period [Integer] The DPO period (default: 20).
14
+ # @return [Array<Float, nil>] The calculated DPO values.
15
+ def self.calculate(data, period: 20)
16
+ output = []
17
+ midpoint = (period / 2) + 1
18
+
19
+ data.each_with_index do |v, i|
20
+ if i < (period + midpoint - 2)
21
+ output << nil
22
+ else
23
+ sma_range = data[(i - midpoint - period + 2)..(i - midpoint + 1)]
24
+ sma = CalculationHelpers.average(sma_range)
25
+ output << (v - sma)
26
+ end
27
+ end
28
+ output
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module IndicatorHub
4
+ module Indicators
5
+ # Daily Return (DR).
6
+ # DR is the percentage change in price from one day to the next.
7
+ class DR
8
+ # Calculates the Daily Return.
9
+ # @param data [Array<Numeric>] The input data points.
10
+ # @return [Array<Float, nil>] The calculated DR values.
11
+ def self.calculate(data)
12
+ output = []
13
+ prev_price = nil
14
+
15
+ data.each do |v|
16
+ output << if prev_price.nil?
17
+ nil
18
+ else
19
+ (v.to_f / prev_price) - 1.0
20
+ end
21
+ prev_price = v
22
+ end
23
+ output
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../calculation_helpers"
4
+
5
+ module IndicatorHub
6
+ module Indicators
7
+ # Exponential Moving Average (EMA).
8
+ # EMA is a type of moving average that places a greater weight and significance
9
+ # on the most recent data points.
10
+ class EMA
11
+ # Calculates the Exponential Moving Average.
12
+ # @param data [Array<Numeric>] The input data points.
13
+ # @param period [Integer] The EMA period (default: 20).
14
+ # @return [Array<Float, nil>] The calculated EMA values.
15
+ def self.calculate(data, period: 20)
16
+ period = period.to_i
17
+ Validation.validate_numeric_data(data)
18
+ return Array.new(data.size, nil) if data.size < period
19
+
20
+ period_values = []
21
+ previous_ema = nil
22
+ output = []
23
+
24
+ data.each do |v|
25
+ period_values << v
26
+
27
+ if period_values.size == period
28
+ ema = CalculationHelpers.ema(v, period_values, period, previous_ema)
29
+ previous_ema = ema
30
+ output << ema
31
+ period_values.shift
32
+ else
33
+ output << nil
34
+ end
35
+ end
36
+ output
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "ema"
4
+
5
+ module IndicatorHub
6
+ module Indicators
7
+ # Envelopes EMA.
8
+ # Envelopes consist of an EMA and two lines plotted at a percentage distance
9
+ # above and below the EMA.
10
+ class EnvelopesEMA
11
+ # Calculates the Envelopes EMA.
12
+ # @param data [Array<Numeric>] The input data points.
13
+ # @param period [Integer] The EMA period (default: 20).
14
+ # @param percentage [Numeric] The percentage distance (default: 5).
15
+ # @return [Array<Hash>] The calculated Envelopes EMA values { upper: Float, middle: Float, lower: Float }.
16
+ def self.calculate(data, period: 20, percentage: 5)
17
+ # Ensure we're working with numbers, if not, something upstream went wrong,
18
+ # but EMA.calculate will catch it if it expects only numbers.
19
+ ema_values = EMA.calculate(data, period: period)
20
+
21
+ output = []
22
+ ema_values.each do |ema|
23
+ if ema.nil?
24
+ output << { upper: nil, middle: nil, lower: nil }
25
+ else
26
+ upper = ema * (1 + (percentage / 100.0))
27
+ lower = ema * (1 - (percentage / 100.0))
28
+ output << { upper: upper, middle: ema, lower: lower }
29
+ end
30
+ end
31
+
32
+ output
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../calculation_helpers"
4
+
5
+ module IndicatorHub
6
+ module Indicators
7
+ # Ease of Movement (EOM).
8
+ # EOM is a momentum oscillator that emphasizes the relationship between
9
+ # price change and volume.
10
+ class EOM
11
+ # Calculates the Ease of Movement.
12
+ # @param data [Array<Hash>] Array of OHLCV hashes.
13
+ # @param period [Integer] The EOM period (default: 14).
14
+ # @return [Array<Float, nil>] The calculated EOM values.
15
+ def self.calculate(data, period: 14)
16
+ output = []
17
+ emv_values = []
18
+ prev_v = nil
19
+
20
+ data.each do |v|
21
+ if prev_v.nil?
22
+ output << nil
23
+ prev_v = v
24
+ next
25
+ end
26
+
27
+ distance_moved = ((v[:high] + v[:low]) / 2.0) - ((prev_v[:high] + prev_v[:low]) / 2.0)
28
+
29
+ range = (v[:high] - v[:low])
30
+ box_ratio = range.zero? ? 0 : (v[:volume] / 100_000_000.0) / range
31
+
32
+ emv = box_ratio.zero? ? 0 : distance_moved / box_ratio
33
+ emv_values << emv
34
+
35
+ if emv_values.size == period
36
+ output << CalculationHelpers.average(emv_values)
37
+ emv_values.shift
38
+ else
39
+ output << nil
40
+ end
41
+
42
+ prev_v = v
43
+ end
44
+ output
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "ema"
4
+
5
+ module IndicatorHub
6
+ module Indicators
7
+ # Force Index (FI).
8
+ # FI is an oscillator that uses price and volume to assess the power behind
9
+ # a move and identify potential turning points.
10
+ class FI
11
+ # Calculates the Force Index.
12
+ # @param data [Array<Numeric, Hash>] Array of prices or OHLCV hashes.
13
+ # @param period [Integer] The FI period (default: 13).
14
+ # @return [Array<Float, nil>] The calculated FI values.
15
+ def self.calculate(data, period: 13)
16
+ return [] if data.empty?
17
+
18
+ raw_fi = []
19
+ prev_close = nil
20
+
21
+ data.each do |v|
22
+ if v.is_a?(Hash)
23
+ close = (v[:close] || v["close"]).to_f
24
+ volume = (v[:volume] || v["volume"]).to_f
25
+ else
26
+ close = v.to_f
27
+ volume = 1.0
28
+ end
29
+
30
+ if prev_close.nil?
31
+ # First data point has no previous close to calculate force index
32
+ else
33
+ raw_fi << ((close - prev_close) * volume)
34
+ end
35
+ prev_close = close
36
+ end
37
+
38
+ return Array.new(data.size, nil) if raw_fi.empty?
39
+
40
+ ema_fi = EMA.calculate(raw_fi, period: period)
41
+ [nil] + ema_fi
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../calculation_helpers"
4
+
5
+ module IndicatorHub
6
+ module Indicators
7
+ # Ichimoku Cloud.
8
+ # The Ichimoku Cloud is a collection of technical indicators that show
9
+ # support and resistance levels, as well as momentum and trend direction.
10
+ class Ichimoku
11
+ # Calculates the Ichimoku Cloud components.
12
+ # @param data [Array<Hash>] Array of OHLCV hashes.
13
+ # @param low_period [Integer] Tenkan-sen period (default: 9).
14
+ # @param medium_period [Integer] Kijun-sen period (default: 26).
15
+ # @param high_period [Integer] Senkou Span B period (default: 52).
16
+ # @return [Array<Hash, nil>] The calculated Ichimoku components.
17
+ def self.calculate(data, low_period: 9, medium_period: 26, high_period: 52)
18
+ # Expects array of hashes with :high, :low, :close
19
+ output = []
20
+
21
+ data.each_with_index do |_, index|
22
+ if index < high_period + medium_period - 2
23
+ output << nil
24
+ next
25
+ end
26
+
27
+ tenkan_sen = calculate_midpoint(index, low_period, data)
28
+ kijun_sen = calculate_midpoint(index, medium_period, data)
29
+ senkou_span_a = calculate_senkou_span_a(index, low_period, medium_period, data)
30
+ senkou_span_b = calculate_senkou_span_b(index, medium_period, high_period, data)
31
+ chikou_span = calculate_chikou_span(index, medium_period, data)
32
+
33
+ output << {
34
+ tenkan_sen: tenkan_sen,
35
+ kijun_sen: kijun_sen,
36
+ senkou_span_a: senkou_span_a,
37
+ senkou_span_b: senkou_span_b,
38
+ chikou_span: chikou_span
39
+ }
40
+ end
41
+ output
42
+ end
43
+
44
+ def self.calculate_midpoint(index, period, data)
45
+ start_idx = [0, index - period + 1].max
46
+ period_data = data[start_idx..index]
47
+ highs = period_data.map { |d| d[:high] }
48
+ lows = period_data.map { |d| d[:low] }
49
+ (highs.max + lows.min) / 2.0
50
+ end
51
+
52
+ def self.calculate_senkou_span_a(index, low_period, medium_period, data)
53
+ mp_ago_index = index - (medium_period - 1)
54
+ return nil if mp_ago_index.negative?
55
+
56
+ t_sen = calculate_midpoint(mp_ago_index, low_period, data)
57
+ k_sen = calculate_midpoint(mp_ago_index, medium_period, data)
58
+ (t_sen + k_sen) / 2.0
59
+ end
60
+
61
+ def self.calculate_senkou_span_b(index, medium_period, high_period, data)
62
+ mp_ago_index = index - (medium_period - 1)
63
+ return nil if mp_ago_index.negative?
64
+
65
+ calculate_midpoint(mp_ago_index, high_period, data)
66
+ end
67
+
68
+ def self.calculate_chikou_span(index, medium_period, data)
69
+ mp_ago_index = index - (medium_period - 1)
70
+ return nil if mp_ago_index.negative?
71
+
72
+ data[mp_ago_index][:close]
73
+ end
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../calculation_helpers"
4
+
5
+ module IndicatorHub
6
+ module Indicators
7
+ # Intraday Momentum Index (IMI).
8
+ # IMI is a technical indicator that combines candlestick analysis with
9
+ # the Relative Strength Index (RSI).
10
+ class IMI
11
+ # Calculates the Intraday Momentum Index.
12
+ # @param data [Array<Hash>] Array of OHLCV hashes.
13
+ # @param period [Integer] The IMI period (default: 14).
14
+ # @return [Array<Float, nil>] The calculated IMI values.
15
+ def self.calculate(data, period: 14)
16
+ output = []
17
+
18
+ data.each_with_index do |_val, i|
19
+ if i < period - 1
20
+ output << nil
21
+ next
22
+ end
23
+
24
+ slice = data[(i - period + 1)..i]
25
+ gsum = 0.0
26
+ lsum = 0.0
27
+
28
+ slice.each do |d|
29
+ if d[:close] > d[:open]
30
+ gsum += (d[:close] - d[:open])
31
+ elsif d[:close] < d[:open]
32
+ lsum += (d[:open] - d[:close])
33
+ end
34
+ end
35
+
36
+ if (gsum + lsum).zero?
37
+ output << 0.0
38
+ else
39
+ imi = 100.0 * gsum / (gsum + lsum)
40
+ output << imi
41
+ end
42
+ end
43
+
44
+ output
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../calculation_helpers"
4
+
5
+ module IndicatorHub
6
+ module Indicators
7
+ # Keltner Channel (KC).
8
+ # KC is a volatility-based technical indicator composed of three separate lines.
9
+ class KC
10
+ # Calculates the Keltner Channel.
11
+ # @param data [Array<Hash>] Array of OHLCV hashes.
12
+ # @param period [Integer] The KC period (default: 20).
13
+ # @param multiplier [Float] The ATR multiplier (default: 1.5).
14
+ # @return [Array<Hash, nil>] The calculated KC values { upper: Float, middle: Float, lower: Float }.
15
+ def self.calculate(data, period: 20, multiplier: 1.5)
16
+ output = []
17
+ typical_prices = []
18
+ trading_ranges = []
19
+
20
+ data.each do |v|
21
+ tp = (v[:high] + v[:low] + v[:close]) / 3.0
22
+ tr = v[:high] - v[:low]
23
+
24
+ typical_prices << tp
25
+ trading_ranges << tr
26
+
27
+ if typical_prices.size == period
28
+ mb = CalculationHelpers.average(typical_prices)
29
+ tra = CalculationHelpers.average(trading_ranges)
30
+
31
+ output << {
32
+ upper: mb + (tra * multiplier),
33
+ middle: mb,
34
+ lower: mb - (tra * multiplier)
35
+ }
36
+ typical_prices.shift
37
+ trading_ranges.shift
38
+ else
39
+ output << nil
40
+ end
41
+ end
42
+ output
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../calculation_helpers"
4
+ require_relative "sma"
5
+
6
+ module IndicatorHub
7
+ module Indicators
8
+ # Know Sure Thing (KST).
9
+ # KST is a momentum oscillator based on the smoothed rate-of-change of four
10
+ # different timeframes.
11
+ class KST
12
+ # Calculates the Know Sure Thing.
13
+ # @param data [Array<Numeric, Hash>] Array of prices or OHLCV hashes.
14
+ # @param r1 [Integer] ROC period 1 (default: 10).
15
+ # @param r2 [Integer] ROC period 2 (default: 15).
16
+ # @param r3 [Integer] ROC period 3 (default: 20).
17
+ # @param r4 [Integer] ROC period 4 (default: 30).
18
+ # @param s1 [Integer] SMA period 1 (default: 10).
19
+ # @param s2 [Integer] SMA period 2 (default: 10).
20
+ # @param s3 [Integer] SMA period 3 (default: 10).
21
+ # @param s4 [Integer] SMA period 4 (default: 15).
22
+ # @param signal [Integer] Signal line period (default: 9).
23
+ # @return [Array<Hash>] The calculated KST values { kst: Float, signal: Float }.
24
+ def self.calculate(data, r1: 10, r2: 15, r3: 20, r4: 30, s1: 10, s2: 10, s3: 10, s4: 15, signal: 9)
25
+ # Use close prices for KST
26
+ closes = data.map do |v|
27
+ if v.is_a?(Hash)
28
+ (v[:close] || v["close"]).to_f
29
+ else
30
+ v.to_f
31
+ end
32
+ end
33
+
34
+ kst_values = []
35
+ closes.each_with_index do |_, i|
36
+ rcma1 = calculate_rcma(closes, i, r1, s1)
37
+ rcma2 = calculate_rcma(closes, i, r2, s2)
38
+ rcma3 = calculate_rcma(closes, i, r3, s3)
39
+ rcma4 = calculate_rcma(closes, i, r4, s4)
40
+
41
+ if rcma1 && rcma2 && rcma3 && rcma4
42
+ kst = (1.0 * rcma1) + (2.0 * rcma2) + (3.0 * rcma3) + (4.0 * rcma4)
43
+ kst_values << kst
44
+ else
45
+ kst_values << nil
46
+ end
47
+ end
48
+
49
+ # Calculate signal line (SMA of KST)
50
+ valid_kst = kst_values.compact
51
+ if valid_kst.size >= signal
52
+ signal_line_valid = CalculationHelpers.sma(valid_kst, signal)
53
+ lead_nils_count = kst_values.count(nil)
54
+ full_signal_line = Array.new(lead_nils_count, nil) + signal_line_valid
55
+ else
56
+ full_signal_line = Array.new(kst_values.size, nil)
57
+ end
58
+
59
+ kst_values.zip(full_signal_line).map do |kst, sig|
60
+ { kst: kst, signal: sig }
61
+ end
62
+ end
63
+
64
+ def self.calculate_rcma(data, index, roc, sma)
65
+ # ROC = (Price(t) - Price(t-roc)) / Price(t-roc) * 100
66
+ # RCMA = SMA of ROC over 'sma' periods
67
+ return nil if index < (roc + sma)
68
+
69
+ roc_data = []
70
+ ((index - sma + 1)..index).each do |i|
71
+ current_price = data[i]
72
+ past_price = data[i - roc]
73
+ return nil if past_price.nil? || past_price.zero?
74
+
75
+ roc_data << ((current_price - past_price) / past_price.to_f * 100.0)
76
+ end
77
+ CalculationHelpers.average(roc_data)
78
+ end
79
+ private_class_method :calculate_rcma
80
+ end
81
+ end
82
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "ema"
4
+
5
+ module IndicatorHub
6
+ module Indicators
7
+ # Moving Average Convergence Divergence (MACD).
8
+ # MACD is a trend-following momentum indicator that shows the relationship
9
+ # between two moving averages of a security’s price.
10
+ class MACD
11
+ # Calculates the Moving Average Convergence Divergence.
12
+ # @param data [Array<Numeric>] The input data points.
13
+ # @param fast_period [Integer] The fast EMA period (default: 12).
14
+ # @param slow_period [Integer] The slow EMA period (default: 26).
15
+ # @param signal_period [Integer] The signal EMA period (default: 9).
16
+ # @return [Array<Hash, nil>] The calculated MACD components (macd, signal, histogram).
17
+ def self.calculate(data, fast_period: 12, slow_period: 26, signal_period: 9)
18
+ fast_ema = EMA.calculate(data, period: fast_period)
19
+ slow_ema = EMA.calculate(data, period: slow_period)
20
+
21
+ macd_line = []
22
+ data.each_with_index do |_, i|
23
+ macd_line << (fast_ema[i] - slow_ema[i] if fast_ema[i] && slow_ema[i])
24
+ end
25
+
26
+ # Signal Line is EMA of MACD line (ignoring initial nil values)
27
+ compact_macd = macd_line.compact
28
+ compact_signal = EMA.calculate(compact_macd, period: signal_period)
29
+
30
+ # Re-align signal line with original data
31
+ signal_line = Array.new(macd_line.size - compact_signal.size, nil) + compact_signal
32
+
33
+ output = []
34
+ data.each_with_index do |_, i|
35
+ histogram = macd_line[i] && signal_line[i] ? (macd_line[i] - signal_line[i]) : nil
36
+ output << {
37
+ macd: macd_line[i],
38
+ signal: signal_line[i],
39
+ histogram: histogram
40
+ }
41
+ end
42
+ output
43
+ end
44
+ end
45
+ end
46
+ end