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,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../calculation_helpers"
4
+
5
+ module IndicatorHub
6
+ module Indicators
7
+ # Money Flow Index (MFI).
8
+ # MFI is a technical oscillator that uses price and volume for identifying
9
+ # overbought or oversold signals in an asset.
10
+ class MFI
11
+ # Calculates the Money Flow Index.
12
+ # @param data [Array<Hash>] Array of OHLCV hashes.
13
+ # @param period [Integer] The MFI period (default: 14).
14
+ # @return [Array<Float, nil>] The calculated MFI values.
15
+ def self.calculate(data, period: 14)
16
+ return Array.new(data.size, nil) if data.size <= period
17
+
18
+ output = []
19
+ typical_prices = []
20
+ raw_money_flows = []
21
+
22
+ data.each_with_index do |v, i|
23
+ typical_price = (v[:high] + v[:low] + v[:close]) / 3.0
24
+ typical_prices << typical_price
25
+
26
+ if i.zero?
27
+ raw_money_flows << 0.0
28
+ output << nil
29
+ next
30
+ end
31
+
32
+ money_flow = typical_price * v[:volume]
33
+ raw_money_flows << if typical_price > typical_prices[i - 1]
34
+ money_flow
35
+ elsif typical_price < typical_prices[i - 1]
36
+ -money_flow
37
+ else
38
+ 0.0
39
+ end
40
+
41
+ if raw_money_flows.size > period
42
+ current_flows = raw_money_flows.last(period)
43
+ pos_flow = current_flows.select(&:positive?).sum
44
+ neg_flow = current_flows.select(&:negative?).sum.abs
45
+
46
+ if neg_flow.zero?
47
+ mfi = 100.0
48
+ else
49
+ mfr = pos_flow / neg_flow
50
+ mfi = 100.0 - (100.0 / (1.0 + mfr))
51
+ end
52
+ output << mfi
53
+ else
54
+ output << nil
55
+ end
56
+ end
57
+
58
+ output
59
+ end
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../calculation_helpers"
4
+
5
+ module IndicatorHub
6
+ module Indicators
7
+ # Mass Index (MI).
8
+ # MI is a technical indicator used to predict trend reversals by analyzing
9
+ # the narrowing and widening of the trading range.
10
+ class MI
11
+ # Calculates the Mass Index.
12
+ # @param data [Array<Hash>] Array of OHLCV hashes.
13
+ # @param ema_period [Integer] The EMA period (default: 9).
14
+ # @param period [Integer] The summation period (default: 25).
15
+ # @return [Array<Float, nil>] The calculated MI values.
16
+ def self.calculate(data, ema_period: 9, period: 25)
17
+ high_low_diffs = data.map do |v|
18
+ if v.is_a?(Hash)
19
+ ((v[:high] || v["high"]).to_f - (v[:low] || v["low"]).to_f)
20
+ else
21
+ 0.0
22
+ end
23
+ end
24
+
25
+ # First EMA
26
+ single_ema_values = calculate_ema(high_low_diffs, ema_period)
27
+
28
+ # Second EMA (EMA of the first EMA)
29
+ valid_single_emas = single_ema_values.compact
30
+ if valid_single_emas.size >= ema_period
31
+ double_ema_valid_values = calculate_ema(valid_single_emas, ema_period)
32
+ # Re-align double_ema_values with single_ema_values
33
+ lead_nils = single_ema_values.size - valid_single_emas.size
34
+ double_ema_values = Array.new(lead_nils, nil) + double_ema_valid_values
35
+ else
36
+ double_ema_values = Array.new(single_ema_values.size, nil)
37
+ end
38
+
39
+ ratios = []
40
+ single_ema_values.each_with_index do |s_ema, i|
41
+ d_ema = double_ema_values[i]
42
+ ratios << (s_ema / d_ema if s_ema && d_ema && d_ema != 0)
43
+ end
44
+
45
+ output = []
46
+ current_ratios = []
47
+ ratios.each do |ratio|
48
+ if ratio.nil?
49
+ output << nil
50
+ else
51
+ current_ratios << ratio
52
+ current_ratios.shift if current_ratios.size > period
53
+
54
+ output << (current_ratios.sum if current_ratios.size == period)
55
+ end
56
+ end
57
+
58
+ output
59
+ end
60
+
61
+ def self.calculate_ema(data, period)
62
+ output = []
63
+ period_values = []
64
+ previous_ema = nil
65
+
66
+ data.each do |v|
67
+ period_values << v
68
+ if period_values.size == period
69
+ ema = CalculationHelpers.ema(v, period_values, period, previous_ema)
70
+ previous_ema = ema
71
+ output << ema
72
+ period_values.shift
73
+ else
74
+ output << nil
75
+ end
76
+ end
77
+ output
78
+ end
79
+ end
80
+ end
81
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../calculation_helpers"
4
+
5
+ module IndicatorHub
6
+ module Indicators
7
+ # Negative Volume Index (NVI).
8
+ # NVI is a technical indicator used to identify market trends based on
9
+ # days when volume decreases.
10
+ class NVI
11
+ # Calculates the Negative Volume Index.
12
+ # @param data [Array<Hash>] Array of OHLCV hashes.
13
+ # @return [Array<Float>] The calculated NVI values.
14
+ def self.calculate(data)
15
+ nvi_cumulative = 1_000.00
16
+ output = []
17
+
18
+ # We need at least two points to calculate change
19
+ return [] if data.empty?
20
+
21
+ data[0]
22
+ output << nvi_cumulative # Start with default of 1_000 for the first point
23
+
24
+ (1...data.length).each do |i|
25
+ v = data[i]
26
+ prev_v = data[i - 1]
27
+
28
+ volume_change = ((v[:volume] - prev_v[:volume]) / prev_v[:volume].to_f)
29
+
30
+ if volume_change.negative?
31
+ price_change = (v[:close] - prev_v[:close]) / prev_v[:close].to_f
32
+ nvi_cumulative *= (1.0 + price_change)
33
+ end
34
+
35
+ output << nvi_cumulative
36
+ end
37
+
38
+ output
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../calculation_helpers"
4
+
5
+ module IndicatorHub
6
+ module Indicators
7
+ # On-Balance Volume (OBV).
8
+ # OBV is a technical momentum indicator that uses volume flow to predict
9
+ # changes in stock price.
10
+ class OBV
11
+ # Calculates the On-Balance Volume.
12
+ # @param data [Array<Hash>] Array of OHLCV hashes.
13
+ # @return [Array<Float>] The calculated OBV values.
14
+ def self.calculate(data)
15
+ current_obv = 0.0
16
+ output = []
17
+ return [] if data.empty?
18
+
19
+ prior_close = nil
20
+
21
+ data.each do |v|
22
+ volume = v[:volume]
23
+ close = v[:close]
24
+
25
+ unless prior_close.nil?
26
+ if close > prior_close
27
+ current_obv += volume
28
+ elsif close < prior_close
29
+ current_obv -= volume
30
+ end
31
+ end
32
+
33
+ output << current_obv
34
+ prior_close = close
35
+ end
36
+
37
+ output
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../calculation_helpers"
4
+
5
+ module IndicatorHub
6
+ module Indicators
7
+ # On-Balance Volume Mean (OBV Mean).
8
+ # OBV Mean is the average of the On-Balance Volume over a specified period.
9
+ class OBVMean
10
+ # Calculates the On-Balance Volume Mean.
11
+ # @param data [Array<Hash>] Array of OHLCV hashes.
12
+ # @param period [Integer] The OBV Mean period (default: 10).
13
+ # @return [Array<Float, nil>] The calculated OBV Mean values.
14
+ def self.calculate(data, period: 10)
15
+ current_obv = 0.0
16
+ obvs = []
17
+ output = []
18
+ prior_close = nil
19
+
20
+ data.each do |v|
21
+ volume = v[:volume]
22
+ close = v[:close]
23
+
24
+ unless prior_close.nil?
25
+ if close > prior_close
26
+ current_obv += volume
27
+ elsif close < prior_close
28
+ current_obv -= volume
29
+ end
30
+ obvs << current_obv
31
+ end
32
+
33
+ prior_close = close
34
+
35
+ output << (IndicatorHub::CalculationHelpers.average(obvs.last(period)) if obvs.size >= period)
36
+ end
37
+
38
+ output
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module IndicatorHub
4
+ module Indicators
5
+ # Pivot Points.
6
+ # Pivot Points are used to identify potential support and resistance levels.
7
+ class PivotPoints
8
+ include CalculationHelpers
9
+
10
+ # Calculates the Pivot Points.
11
+ # @param data [Array<Hash>] Array of OHLCV hashes.
12
+ # @return [Array<Hash>] The calculated Pivot Points values { p: Float, s1: Float, s2: Float, s3: Float, r1: Float, r2: Float, r3: Float }.
13
+ def self.calculate(data)
14
+ return [] unless data.is_a?(Array) && !data.empty?
15
+
16
+ data.map do |bar|
17
+ high = bar[:high]
18
+ low = bar[:low]
19
+ close = bar[:close]
20
+
21
+ p = ((high + low + close) / 3.0).round(4)
22
+
23
+ s1 = ((2 * p) - high).round(4)
24
+ s2 = (p - (high - low)).round(4)
25
+ s3 = (low - (2 * (high - p))).round(4)
26
+
27
+ r1 = ((2 * p) - low).round(4)
28
+ r2 = (p + (high - low)).round(4)
29
+ r3 = (high + (2 * (p - low))).round(4)
30
+
31
+ {
32
+ p: p,
33
+ s1: s1,
34
+ s2: s2,
35
+ s3: s3,
36
+ r1: r1,
37
+ r2: r2,
38
+ r3: r3
39
+ }
40
+ end
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module IndicatorHub
4
+ module Indicators
5
+ # Price Channel.
6
+ # Price Channel is a technical indicator that identifies the high and low
7
+ # prices over a specified period.
8
+ class PriceChannel
9
+ include CalculationHelpers
10
+
11
+ # Calculates the Price Channel.
12
+ # @param data [Array<Hash>] Array of OHLCV hashes.
13
+ # @param period [Integer] The Price Channel period (default: 20).
14
+ # @return [Array<Hash>] The calculated Price Channel values { upper: Float, lower: Float }.
15
+ def self.calculate(data, period: 20)
16
+ return [] if data.length < period
17
+
18
+ highs = data.map { |d| d[:high] }
19
+ lows = data.map { |d| d[:low] }
20
+
21
+ results = []
22
+ (0...data.length).each do |i|
23
+ if i < period - 1
24
+ results << { upper: nil, lower: nil }
25
+ else
26
+ current_highs = highs[(i - period + 1)..i]
27
+ current_lows = lows[(i - period + 1)..i]
28
+ results << {
29
+ upper: current_highs.max,
30
+ lower: current_lows.min
31
+ }
32
+ end
33
+ end
34
+ results
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module IndicatorHub
4
+ module Indicators
5
+ # QStick.
6
+ # QStick is a technical indicator that identifies the trend of a security's
7
+ # price by calculating the moving average of the difference between open and close.
8
+ class QStick
9
+ include CalculationHelpers
10
+
11
+ # Calculates the QStick.
12
+ # @param data [Array<Hash>] Array of OHLCV hashes.
13
+ # @param period [Integer] The QStick period (default: 10).
14
+ # @return [Array<Float, nil>] The calculated QStick values.
15
+ def self.calculate(data, period: 10)
16
+ return [] if data.length < period
17
+
18
+ opens = data.map { |d| d[:open] }
19
+ closes = data.map { |d| d[:close] }
20
+
21
+ diffs = closes.zip(opens).map { |c, o| c - o }
22
+
23
+ results = []
24
+ (0...data.length).each do |i|
25
+ if i < period - 1
26
+ results << nil
27
+ else
28
+ current_diffs = diffs[(i - period + 1)..i]
29
+ results << (current_diffs.sum.to_f / period).round(4)
30
+ end
31
+ end
32
+ results
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module IndicatorHub
4
+ module Indicators
5
+ # Relative Momentum Index (RMI).
6
+ # RMI is a variation of the RSI that uses momentum instead of price change.
7
+ class RMI
8
+ include CalculationHelpers
9
+
10
+ # Calculates the Relative Momentum Index.
11
+ # @param data [Array<Numeric, Hash>] Array of prices or OHLCV hashes.
12
+ # @param period [Integer] RMI smoothing period (default: 14).
13
+ # @param momentum_period [Integer] Momentum period (default: 5).
14
+ # @return [Array<Float, nil>] The calculated RMI values.
15
+ def self.calculate(data, period: 14, momentum_period: 5)
16
+ prices = data.is_a?(Array) && data.first.is_a?(Hash) ? data.map { |d| d[:close] } : data
17
+ return Array.new(prices.length, nil) if prices.length < momentum_period + period
18
+
19
+ ups = []
20
+ downs = []
21
+
22
+ # Calculate momentum changes
23
+ (momentum_period...prices.length).each do |i|
24
+ change = prices[i] - prices[i - momentum_period]
25
+ ups << (change.positive? ? change : 0)
26
+ downs << (change.negative? ? change.abs : 0)
27
+ end
28
+
29
+ # Wilder's smoothing on ups and downs
30
+ avg_ups = WildersSmoothing.calculate(ups, period: period)
31
+ avg_downs = WildersSmoothing.calculate(downs, period: period)
32
+
33
+ results = Array.new(momentum_period, nil)
34
+ (0...avg_ups.length).each do |i|
35
+ if avg_ups[i].nil? || avg_downs[i].nil?
36
+ results << nil
37
+ elsif avg_downs[i].zero?
38
+ results << 100.0
39
+ else
40
+ rs = avg_ups[i].to_f / avg_downs[i]
41
+ results << (100.0 - (100.0 / (1.0 + rs))).round(4)
42
+ end
43
+ end
44
+ results
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module IndicatorHub
4
+ module Indicators
5
+ # Rate of Change (ROC).
6
+ # ROC is a momentum oscillator that measures the percentage change in price
7
+ # between the current price and the price a certain number of periods ago.
8
+ class ROC
9
+ include CalculationHelpers
10
+
11
+ # Calculates the Rate of Change.
12
+ # @param prices [Array<Numeric>] Array of prices.
13
+ # @param period [Integer] The ROC period (default: 12).
14
+ # @return [Array<Float, nil>] The calculated ROC values.
15
+ def self.calculate(prices, period: 12)
16
+ return Array.new(prices.length, nil) if prices.length < period
17
+
18
+ results = []
19
+ (0...prices.length).each do |i|
20
+ if i < period
21
+ results << nil
22
+ else
23
+ current_price = prices[i]
24
+ lookback_price = prices[i - period]
25
+
26
+ results << if lookback_price.zero?
27
+ 0.0
28
+ else
29
+ (((current_price - lookback_price).to_f / lookback_price) * 100).round(4)
30
+ end
31
+ end
32
+ end
33
+ results
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../calculation_helpers"
4
+
5
+ module IndicatorHub
6
+ module Indicators
7
+ # Relative Strength Index (RSI).
8
+ # RSI is a momentum oscillator that measures the speed and change of price movements.
9
+ # It oscillates between 0 and 100. Traditionally, RSI is considered overbought when
10
+ # above 70 and oversold when below 30.
11
+ class RSI
12
+ # Calculates the Relative Strength Index.
13
+ # @param data [Array<Numeric>] The input data points.
14
+ # @param period [Integer] The RSI period (default: 14).
15
+ # @return [Array<Float, nil>] The calculated RSI values.
16
+ def self.calculate(data, period: 14)
17
+ return [] if data.size < period
18
+
19
+ output = []
20
+ gains = []
21
+ losses = []
22
+ prev_price = data.first
23
+
24
+ avg_gain = nil
25
+ avg_loss = nil
26
+
27
+ data.each_with_index do |price, index|
28
+ if index.zero?
29
+ output << nil
30
+ next
31
+ end
32
+
33
+ change = price - prev_price
34
+ gains << (change.positive? ? change : 0.0)
35
+ losses << (change.negative? ? change.abs : 0.0)
36
+
37
+ if gains.size == period
38
+ if avg_gain.nil?
39
+ # Initial average gain/loss is SMA
40
+ avg_gain = CalculationHelpers.average(gains)
41
+ avg_loss = CalculationHelpers.average(losses)
42
+ else
43
+ # Subsequent use Wilder's Smoothing
44
+ avg_gain = CalculationHelpers.wilder_smoothing(avg_gain, gains.last, period)
45
+ avg_loss = CalculationHelpers.wilder_smoothing(avg_loss, losses.last, period)
46
+ end
47
+
48
+ if avg_loss.zero?
49
+ rsi = avg_gain.zero? ? 0.0 : 100.0
50
+ else
51
+ rs = avg_gain / avg_loss
52
+ rsi = 100.0 - (100.0 / (1.0 + rs))
53
+ end
54
+ output << rsi
55
+ gains.shift
56
+ losses.shift
57
+ else
58
+ output << nil
59
+ end
60
+
61
+ prev_price = price
62
+ end
63
+ output
64
+ end
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base_indicator"
4
+
5
+ module IndicatorHub
6
+ module Indicators
7
+ # Simple Moving Average (SMA).
8
+ # SMA is a basic technical indicator that calculates the average price over a
9
+ # specified number of periods.
10
+ class SMA < BaseIndicator
11
+ # Calculates the Simple Moving Average.
12
+ # @return [Array<Float, nil>] The calculated SMA values.
13
+ def calculate
14
+ period = options[:period] || 20
15
+ CalculationHelpers.sma(numeric_data, period)
16
+ end
17
+
18
+ # Defines the list of valid option keys for this indicator.
19
+ # @return [Array<Symbol>]
20
+ def self.valid_options
21
+ %i[field period]
22
+ end
23
+
24
+ # Defines the minimum number of data points required for calculation.
25
+ # @param options [Hash] The current indicator options.
26
+ # @return [Integer]
27
+ def self.min_data_size(options)
28
+ options[:period] || 20
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ module IndicatorHub
4
+ module Indicators
5
+ # Stochastic Oscillator (SO).
6
+ # SO is a momentum indicator comparing a particular closing price of a
7
+ # security to a range of its prices over a certain period of time.
8
+ class SO
9
+ include CalculationHelpers
10
+
11
+ # Calculates the Stochastic Oscillator.
12
+ # @param data [Array<Hash>] Array of OHLCV hashes.
13
+ # @param k_period [Integer] Period for %K calculation (default: 14).
14
+ # @param k_slowing [Integer] Smoothing for %K (default: 3).
15
+ # @param d_period [Integer] Period for %D calculation (default: 3).
16
+ # @return [Array<Hash>] The calculated SO values { k: Float, d: Float }.
17
+ def self.calculate(data, k_period: 14, k_slowing: 3, d_period: 3)
18
+ highs = data.map { |d| d[:high] }
19
+ lows = data.map { |d| d[:low] }
20
+ closes = data.map { |d| d[:close] }
21
+
22
+ fast_ks = []
23
+ data.each_with_index do |_, i|
24
+ if i < k_period - 1
25
+ fast_ks << nil
26
+ else
27
+ current_highs = highs[(i - k_period + 1)..i]
28
+ current_lows = lows[(i - k_period + 1)..i]
29
+
30
+ hh = current_highs.max
31
+ ll = current_lows.min
32
+
33
+ fast_ks << if hh == ll
34
+ 100.0
35
+ else
36
+ ((closes[i] - ll) / (hh - ll) * 100.0)
37
+ end
38
+ end
39
+ end
40
+
41
+ # Slow %K is SMA of fast %K
42
+ slow_ks = []
43
+ if k_slowing > 1
44
+ fast_ks.each_with_index do |_, i|
45
+ if i < (k_period - 1) + (k_slowing - 1)
46
+ slow_ks << nil
47
+ else
48
+ period_values = fast_ks[(i - k_slowing + 1)..i]
49
+ slow_ks << (period_values.sum / k_slowing.to_f)
50
+ end
51
+ end
52
+ else
53
+ slow_ks = fast_ks
54
+ end
55
+
56
+ # %D is SMA of slow %K
57
+ ds = []
58
+ slow_ks.each_with_index do |_, i|
59
+ if i < (k_period - 1) + (k_slowing > 1 ? (k_slowing - 1) : 0) + (d_period - 1)
60
+ ds << nil
61
+ else
62
+ period_values = slow_ks[(i - d_period + 1)..i]
63
+ ds << (period_values.sum / d_period.to_f)
64
+ end
65
+ end
66
+
67
+ data.map.with_index do |_, i|
68
+ {
69
+ k: slow_ks[i]&.round(4),
70
+ d: ds[i]&.round(4)
71
+ }
72
+ end
73
+ end
74
+ end
75
+ end
76
+ end