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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +5 -0
- data/CODE_OF_CONDUCT.md +10 -0
- data/LICENSE.txt +21 -0
- data/README.md +551 -0
- data/Rakefile +12 -0
- data/exe/indicator_hub +4 -0
- data/lib/indicator_hub/calculation_helpers.rb +196 -0
- data/lib/indicator_hub/indicators/adi.rb +37 -0
- data/lib/indicator_hub/indicators/adtv.rb +27 -0
- data/lib/indicator_hub/indicators/adx.rb +102 -0
- data/lib/indicator_hub/indicators/ao.rb +39 -0
- data/lib/indicator_hub/indicators/atr.rb +45 -0
- data/lib/indicator_hub/indicators/base_indicator.rb +68 -0
- data/lib/indicator_hub/indicators/bb.rb +44 -0
- data/lib/indicator_hub/indicators/cci.rb +41 -0
- data/lib/indicator_hub/indicators/cmf.rb +54 -0
- data/lib/indicator_hub/indicators/cmo.rb +49 -0
- data/lib/indicator_hub/indicators/cr.rb +26 -0
- data/lib/indicator_hub/indicators/dc.rb +40 -0
- data/lib/indicator_hub/indicators/dlr.rb +27 -0
- data/lib/indicator_hub/indicators/dpo.rb +32 -0
- data/lib/indicator_hub/indicators/dr.rb +27 -0
- data/lib/indicator_hub/indicators/ema.rb +40 -0
- data/lib/indicator_hub/indicators/envelopes_ema.rb +36 -0
- data/lib/indicator_hub/indicators/eom.rb +48 -0
- data/lib/indicator_hub/indicators/fi.rb +45 -0
- data/lib/indicator_hub/indicators/ichimoku.rb +76 -0
- data/lib/indicator_hub/indicators/imi.rb +48 -0
- data/lib/indicator_hub/indicators/kc.rb +46 -0
- data/lib/indicator_hub/indicators/kst.rb +82 -0
- data/lib/indicator_hub/indicators/macd.rb +46 -0
- data/lib/indicator_hub/indicators/mfi.rb +62 -0
- data/lib/indicator_hub/indicators/mi.rb +81 -0
- data/lib/indicator_hub/indicators/nvi.rb +42 -0
- data/lib/indicator_hub/indicators/obv.rb +41 -0
- data/lib/indicator_hub/indicators/obv_mean.rb +42 -0
- data/lib/indicator_hub/indicators/pivot_points.rb +44 -0
- data/lib/indicator_hub/indicators/price_channel.rb +38 -0
- data/lib/indicator_hub/indicators/qstick.rb +36 -0
- data/lib/indicator_hub/indicators/rmi.rb +48 -0
- data/lib/indicator_hub/indicators/roc.rb +37 -0
- data/lib/indicator_hub/indicators/rsi.rb +67 -0
- data/lib/indicator_hub/indicators/sma.rb +32 -0
- data/lib/indicator_hub/indicators/so.rb +76 -0
- data/lib/indicator_hub/indicators/trix.rb +53 -0
- data/lib/indicator_hub/indicators/tsi.rb +67 -0
- data/lib/indicator_hub/indicators/uo.rb +67 -0
- data/lib/indicator_hub/indicators/vi.rb +54 -0
- data/lib/indicator_hub/indicators/volume_oscillator.rb +55 -0
- data/lib/indicator_hub/indicators/vpt.rb +35 -0
- data/lib/indicator_hub/indicators/vwap.rb +33 -0
- data/lib/indicator_hub/indicators/wilders_smoothing.rb +36 -0
- data/lib/indicator_hub/indicators/wma.rb +36 -0
- data/lib/indicator_hub/indicators/wr.rb +36 -0
- data/lib/indicator_hub/series.rb +79 -0
- data/lib/indicator_hub/talib_adapter.rb +23 -0
- data/lib/indicator_hub/validation.rb +49 -0
- data/lib/indicator_hub/version.rb +6 -0
- data/lib/indicator_hub.rb +485 -0
- data/sig/indicator_hub.rbs +4 -0
- metadata +112 -0
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../calculation_helpers"
|
|
4
|
+
require_relative "ema"
|
|
5
|
+
|
|
6
|
+
module IndicatorHub
|
|
7
|
+
module Indicators
|
|
8
|
+
# Triple Exponential Average (TRIX).
|
|
9
|
+
# TRIX is a momentum oscillator that shows the percent rate-of-change of a
|
|
10
|
+
# triple exponentially smoothed moving average.
|
|
11
|
+
class TRIX
|
|
12
|
+
# Calculates the Triple Exponential Average.
|
|
13
|
+
# @param data [Array<Numeric>] The input data points.
|
|
14
|
+
# @param period [Integer] The TRIX period (default: 15).
|
|
15
|
+
# @return [Array<Float, nil>] The calculated TRIX values.
|
|
16
|
+
def self.calculate(data, period: 15)
|
|
17
|
+
period = period.to_i
|
|
18
|
+
|
|
19
|
+
# EMA1 = EMA(price, n)
|
|
20
|
+
ema1 = EMA.calculate(data, period: period)
|
|
21
|
+
|
|
22
|
+
# Filter out nils for next EMA
|
|
23
|
+
ema1_filtered = ema1.compact
|
|
24
|
+
ema2 = EMA.calculate(ema1_filtered, period: period)
|
|
25
|
+
|
|
26
|
+
# EMA2 filtered
|
|
27
|
+
ema2_filtered = ema2.compact
|
|
28
|
+
ema3 = EMA.calculate(ema2_filtered, period: period)
|
|
29
|
+
|
|
30
|
+
# Now we need to align them back to the original data size
|
|
31
|
+
# ema1 has (period - 1) nils at the start
|
|
32
|
+
# ema2 has (period - 1) additional nils
|
|
33
|
+
# ema3 has (period - 1) additional nils
|
|
34
|
+
# Total nils at start of ema3 (aligned to data): 3 * (period - 1)
|
|
35
|
+
|
|
36
|
+
full_ema3 = Array.new(data.size, nil)
|
|
37
|
+
ema3_compact = ema3.compact
|
|
38
|
+
|
|
39
|
+
offset = 3 * (period - 1)
|
|
40
|
+
ema3_compact.each_with_index do |v, i|
|
|
41
|
+
full_ema3[offset + i] = v if offset + i < data.size
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
output = Array.new(data.size, nil)
|
|
45
|
+
(1...data.size).each do |i|
|
|
46
|
+
output[i] = (((full_ema3[i] - full_ema3[i - 1]) / full_ema3[i - 1].to_f) * 100.0) if full_ema3[i] && full_ema3[i - 1]
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
output
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../calculation_helpers"
|
|
4
|
+
require_relative "ema"
|
|
5
|
+
|
|
6
|
+
module IndicatorHub
|
|
7
|
+
module Indicators
|
|
8
|
+
# True Strength Index (TSI).
|
|
9
|
+
# TSI is a technical momentum oscillator used to identify trends and reversals.
|
|
10
|
+
class TSI
|
|
11
|
+
# Calculates the True Strength Index.
|
|
12
|
+
# @param data [Array<Numeric>] The input data points.
|
|
13
|
+
# @param fast_period [Integer] Fast EMA period (default: 13).
|
|
14
|
+
# @param slow_period [Integer] Slow EMA period (default: 25).
|
|
15
|
+
# @return [Array<Float, nil>] The calculated TSI values.
|
|
16
|
+
def self.calculate(data, fast_period: 13, slow_period: 25)
|
|
17
|
+
fast_period = fast_period.to_i
|
|
18
|
+
slow_period = slow_period.to_i
|
|
19
|
+
|
|
20
|
+
return [] if data.size < 2
|
|
21
|
+
|
|
22
|
+
# 1. Momentum and absolute momentum
|
|
23
|
+
momentum = []
|
|
24
|
+
abs_momentum = []
|
|
25
|
+
|
|
26
|
+
(1...data.size).each do |i|
|
|
27
|
+
m = data[i] - data[i - 1]
|
|
28
|
+
momentum << m
|
|
29
|
+
abs_momentum << m.abs
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# 2. First EMA (slow_period)
|
|
33
|
+
ema1_m = EMA.calculate(momentum, period: slow_period)
|
|
34
|
+
ema1_abs_m = EMA.calculate(abs_momentum, period: slow_period)
|
|
35
|
+
|
|
36
|
+
# 3. Second EMA (fast_period)
|
|
37
|
+
ema1_m_filtered = ema1_m.compact
|
|
38
|
+
ema1_abs_m_filtered = ema1_abs_m.compact
|
|
39
|
+
|
|
40
|
+
ema2_m = EMA.calculate(ema1_m_filtered, period: fast_period)
|
|
41
|
+
ema2_abs_m = EMA.calculate(ema1_abs_m_filtered, period: fast_period)
|
|
42
|
+
|
|
43
|
+
# Align results back to original data size
|
|
44
|
+
# momentum size is (data.size - 1)
|
|
45
|
+
# ema1 has (slow_period - 1) nils
|
|
46
|
+
# ema2 has (fast_period - 1) additional nils
|
|
47
|
+
# total nils in ema2 relative to momentum: (slow_period - 1) + (fast_period - 1)
|
|
48
|
+
# relative to original data: 1 + (slow_period - 1) + (fast_period - 1) = slow_period + fast_period - 1
|
|
49
|
+
|
|
50
|
+
offset = slow_period + fast_period - 1
|
|
51
|
+
output = Array.new(data.size, nil)
|
|
52
|
+
|
|
53
|
+
ema2_m_compact = ema2_m.compact
|
|
54
|
+
ema2_abs_m_compact = ema2_abs_m.compact
|
|
55
|
+
|
|
56
|
+
ema2_m_compact.each_with_index do |v, i|
|
|
57
|
+
if offset + i < data.size
|
|
58
|
+
div = ema2_abs_m_compact[i]
|
|
59
|
+
output[offset + i] = div.zero? ? 0.0 : 100.0 * (v / div.to_f)
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
output
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../calculation_helpers"
|
|
4
|
+
|
|
5
|
+
module IndicatorHub
|
|
6
|
+
module Indicators
|
|
7
|
+
# Ultimate Oscillator (UO).
|
|
8
|
+
# UO is a technical indicator that combines price action over three
|
|
9
|
+
# different timeframes into a single momentum oscillator.
|
|
10
|
+
class UO
|
|
11
|
+
# Calculates the Ultimate Oscillator.
|
|
12
|
+
# @param data [Array<Hash>] Array of OHLCV hashes.
|
|
13
|
+
# @param short_period [Integer] Short period (default: 7).
|
|
14
|
+
# @param medium_period [Integer] Medium period (default: 14).
|
|
15
|
+
# @param long_period [Integer] Long period (default: 28).
|
|
16
|
+
# @param short_weight [Float] Short period weight (default: 4.0).
|
|
17
|
+
# @param medium_weight [Float] Medium period weight (default: 2.0).
|
|
18
|
+
# @param long_weight [Float] Long period weight (default: 1.0).
|
|
19
|
+
# @return [Array<Float, nil>] The calculated UO values.
|
|
20
|
+
def self.calculate(data, short_period: 7, medium_period: 14, long_period: 28,
|
|
21
|
+
short_weight: 4.0, medium_weight: 2.0, long_weight: 1.0)
|
|
22
|
+
short_period = short_period.to_i
|
|
23
|
+
medium_period = medium_period.to_i
|
|
24
|
+
long_period = long_period.to_i
|
|
25
|
+
|
|
26
|
+
return [] if data.size < 2
|
|
27
|
+
|
|
28
|
+
bp = []
|
|
29
|
+
tr = []
|
|
30
|
+
|
|
31
|
+
(1...data.size).each do |i|
|
|
32
|
+
v = data[i]
|
|
33
|
+
prev_close = data[i - 1][:close]
|
|
34
|
+
|
|
35
|
+
min_low_p_close = [v[:low], prev_close].min
|
|
36
|
+
max_high_p_close = [v[:high], prev_close].max
|
|
37
|
+
|
|
38
|
+
bp << (v[:close] - min_low_p_close)
|
|
39
|
+
tr << (max_high_p_close - min_low_p_close)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
sum_weights = short_weight + medium_weight + long_weight
|
|
43
|
+
output = Array.new(data.size, nil)
|
|
44
|
+
|
|
45
|
+
# We need long_period elements in bp/tr to start
|
|
46
|
+
# bp/tr have size data.size - 1
|
|
47
|
+
((long_period - 1)...bp.size).each do |i|
|
|
48
|
+
avg7 = sum_last(bp, i, short_period) / sum_last(tr, i, short_period).to_f
|
|
49
|
+
avg14 = sum_last(bp, i, medium_period) / sum_last(tr, i, medium_period).to_f
|
|
50
|
+
avg28 = sum_last(bp, i, long_period) / sum_last(tr, i, long_period).to_f
|
|
51
|
+
|
|
52
|
+
uo = 100.0 * ((short_weight * avg7) + (medium_weight * avg14) + (long_weight * avg28)) / sum_weights
|
|
53
|
+
output[i + 1] = uo
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
output
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def self.sum_last(array, index, period)
|
|
60
|
+
start = index - period + 1
|
|
61
|
+
sum = 0.0
|
|
62
|
+
(start..index).each { |j| sum += array[j] }
|
|
63
|
+
sum
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../calculation_helpers"
|
|
4
|
+
|
|
5
|
+
module IndicatorHub
|
|
6
|
+
module Indicators
|
|
7
|
+
# Vortex Indicator (VI).
|
|
8
|
+
# VI is a technical indicator consisting of two lines that identify
|
|
9
|
+
# positive and negative trend movement.
|
|
10
|
+
class VI
|
|
11
|
+
# Calculates the Vortex Indicator.
|
|
12
|
+
# @param data [Array<Hash>] Array of OHLCV hashes.
|
|
13
|
+
# @param period [Integer] The VI period (default: 14).
|
|
14
|
+
# @return [Array<Hash>] The calculated VI values { plus_vi: Float, minus_vi: Float }.
|
|
15
|
+
def self.calculate(data, period: 14)
|
|
16
|
+
output = []
|
|
17
|
+
pos_vms = []
|
|
18
|
+
neg_vms = []
|
|
19
|
+
trs = []
|
|
20
|
+
|
|
21
|
+
data.each_with_index do |val, i|
|
|
22
|
+
if i.zero?
|
|
23
|
+
output << { plus_vi: nil, minus_vi: nil }
|
|
24
|
+
next
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
prev = data[i - 1]
|
|
28
|
+
pos_vm = (val[:high] - prev[:low]).abs
|
|
29
|
+
neg_vm = (val[:low] - prev[:high]).abs
|
|
30
|
+
tr = CalculationHelpers.true_range(val[:high], val[:low], prev[:close])
|
|
31
|
+
|
|
32
|
+
pos_vms << pos_vm
|
|
33
|
+
neg_vms << neg_vm
|
|
34
|
+
trs << tr
|
|
35
|
+
|
|
36
|
+
if pos_vms.size >= period
|
|
37
|
+
sum_pos = CalculationHelpers.sum(pos_vms.last(period))
|
|
38
|
+
sum_neg = CalculationHelpers.sum(neg_vms.last(period))
|
|
39
|
+
sum_tr = CalculationHelpers.sum(trs.last(period))
|
|
40
|
+
|
|
41
|
+
output << {
|
|
42
|
+
plus_vi: (sum_pos / sum_tr.to_f),
|
|
43
|
+
minus_vi: (sum_neg / sum_tr.to_f)
|
|
44
|
+
}
|
|
45
|
+
else
|
|
46
|
+
output << { plus_vi: nil, minus_vi: nil }
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
output
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../calculation_helpers"
|
|
4
|
+
|
|
5
|
+
module IndicatorHub
|
|
6
|
+
module Indicators
|
|
7
|
+
# Volume Oscillator.
|
|
8
|
+
# Volume Oscillator measures the difference between a fast and slow volume
|
|
9
|
+
# moving average.
|
|
10
|
+
class VolumeOscillator
|
|
11
|
+
# Calculates the Volume Oscillator.
|
|
12
|
+
# @param data [Array<Numeric, Hash>] Array of volumes or OHLCV hashes.
|
|
13
|
+
# @param short_period [Integer] Short SMA period (default: 20).
|
|
14
|
+
# @param long_period [Integer] Long SMA period (default: 60).
|
|
15
|
+
# @return [Array<Float, nil>] The calculated Volume Oscillator values.
|
|
16
|
+
def self.calculate(data, short_period: 20, long_period: 60)
|
|
17
|
+
# Handle OHLC data by extracting volume
|
|
18
|
+
numeric_data = if data.is_a?(Array) && data.first.is_a?(Hash)
|
|
19
|
+
data.map { |d| d[:volume] }
|
|
20
|
+
else
|
|
21
|
+
data
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
Validation.validate_numeric_data(numeric_data)
|
|
25
|
+
|
|
26
|
+
output = []
|
|
27
|
+
short_period_values = []
|
|
28
|
+
long_period_values = []
|
|
29
|
+
|
|
30
|
+
numeric_data.each do |v|
|
|
31
|
+
short_period_values << v
|
|
32
|
+
long_period_values << v
|
|
33
|
+
|
|
34
|
+
short_period_values.shift if short_period_values.size > short_period
|
|
35
|
+
long_period_values.shift if long_period_values.size > long_period
|
|
36
|
+
|
|
37
|
+
if long_period_values.size == long_period
|
|
38
|
+
short_sma = CalculationHelpers.average(short_period_values)
|
|
39
|
+
long_sma = CalculationHelpers.average(long_period_values)
|
|
40
|
+
|
|
41
|
+
vo = if long_sma.zero?
|
|
42
|
+
0.0
|
|
43
|
+
else
|
|
44
|
+
((short_sma - long_sma) / long_sma.to_f) * 100.0
|
|
45
|
+
end
|
|
46
|
+
output << vo.round(2)
|
|
47
|
+
else
|
|
48
|
+
output << nil
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
output
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../calculation_helpers"
|
|
4
|
+
|
|
5
|
+
module IndicatorHub
|
|
6
|
+
module Indicators
|
|
7
|
+
# Volume-Price Trend (VPT).
|
|
8
|
+
# VPT is a technical indicator that combines price and volume to confirm
|
|
9
|
+
# the strength of a price trend or signal its reversal.
|
|
10
|
+
class VPT
|
|
11
|
+
# Calculates the Volume-Price Trend.
|
|
12
|
+
# @param data [Array<Hash>] Array of OHLCV hashes.
|
|
13
|
+
# @return [Array<Float, nil>] The calculated VPT values.
|
|
14
|
+
def self.calculate(data)
|
|
15
|
+
output = []
|
|
16
|
+
prev_vpt = 0.0
|
|
17
|
+
|
|
18
|
+
data.each_with_index do |val, i|
|
|
19
|
+
if i.zero?
|
|
20
|
+
output << nil
|
|
21
|
+
next
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
prev = data[i - 1]
|
|
25
|
+
# VPT = prev_vpt + (volume * (close - prev_close) / prev_close)
|
|
26
|
+
vpt = prev_vpt + (val[:volume] * (val[:close] - prev[:close]) / prev[:close].to_f)
|
|
27
|
+
output << vpt
|
|
28
|
+
prev_vpt = vpt
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
output
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../calculation_helpers"
|
|
4
|
+
|
|
5
|
+
module IndicatorHub
|
|
6
|
+
module Indicators
|
|
7
|
+
# Volume Weighted Average Price (VWAP).
|
|
8
|
+
# VWAP is a technical analysis indicator used on intraday charts that resets
|
|
9
|
+
# at the start of every new trading session.
|
|
10
|
+
class VWAP
|
|
11
|
+
# Calculates the Volume Weighted Average Price.
|
|
12
|
+
# @param data [Array<Hash>] Array of OHLCV hashes.
|
|
13
|
+
# @return [Array<Float, nil>] The calculated VWAP values.
|
|
14
|
+
def self.calculate(data)
|
|
15
|
+
# Expects array of hashes with :high, :low, :close, :volume
|
|
16
|
+
output = []
|
|
17
|
+
cumm_volume = 0.0
|
|
18
|
+
cumm_volume_x_typical_price = 0.0
|
|
19
|
+
|
|
20
|
+
data.each do |v|
|
|
21
|
+
tp = CalculationHelpers.typical_price(v[:high], v[:low], v[:close])
|
|
22
|
+
vol = v[:volume].to_f
|
|
23
|
+
cumm_volume_x_typical_price += vol * tp
|
|
24
|
+
cumm_volume += vol
|
|
25
|
+
|
|
26
|
+
vwap = cumm_volume.positive? ? (cumm_volume_x_typical_price / cumm_volume) : 0.0
|
|
27
|
+
output << vwap
|
|
28
|
+
end
|
|
29
|
+
output
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module IndicatorHub
|
|
4
|
+
module Indicators
|
|
5
|
+
# Wilder's Smoothing.
|
|
6
|
+
# Wilder's Smoothing is a type of exponential moving average used in
|
|
7
|
+
# technical indicators like RSI and ATR.
|
|
8
|
+
class WildersSmoothing
|
|
9
|
+
include CalculationHelpers
|
|
10
|
+
|
|
11
|
+
# Calculates Wilder's Smoothing.
|
|
12
|
+
# @param prices [Array<Numeric>] Array of prices.
|
|
13
|
+
# @param period [Integer] The smoothing period (default: 14).
|
|
14
|
+
# @return [Array<Float, nil>] The calculated Wilder's Smoothing values.
|
|
15
|
+
def self.calculate(prices, period: 14)
|
|
16
|
+
return Array.new(prices.length, nil) if prices.length < period
|
|
17
|
+
|
|
18
|
+
results = Array.new(period - 1, nil)
|
|
19
|
+
|
|
20
|
+
# First Wilder's is a simple average of the first 'period' values
|
|
21
|
+
initial_sma = prices.first(period).sum.to_f / period
|
|
22
|
+
results << initial_sma.round(4)
|
|
23
|
+
|
|
24
|
+
wilders = initial_sma
|
|
25
|
+
alpha = 1.0 / period
|
|
26
|
+
|
|
27
|
+
prices.drop(period).each do |price|
|
|
28
|
+
wilders = ((price - wilders) * alpha) + wilders
|
|
29
|
+
results << wilders.round(4)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
results
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../calculation_helpers"
|
|
4
|
+
|
|
5
|
+
module IndicatorHub
|
|
6
|
+
module Indicators
|
|
7
|
+
# Weighted Moving Average (WMA).
|
|
8
|
+
# WMA is a moving average that assigns more weight to recent data points
|
|
9
|
+
# and less weight to past data points.
|
|
10
|
+
class WMA
|
|
11
|
+
# Calculates the Weighted Moving Average.
|
|
12
|
+
# @param data [Array<Numeric>] The input data points.
|
|
13
|
+
# @param period [Integer] The WMA period (default: 20).
|
|
14
|
+
# @return [Array<Float, nil>] The calculated WMA 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
|
+
output = []
|
|
21
|
+
period_values = []
|
|
22
|
+
|
|
23
|
+
data.each do |v|
|
|
24
|
+
period_values << v
|
|
25
|
+
if period_values.size == period
|
|
26
|
+
output << CalculationHelpers.wma(period_values)
|
|
27
|
+
period_values.shift
|
|
28
|
+
else
|
|
29
|
+
output << nil
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
output
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../calculation_helpers"
|
|
4
|
+
|
|
5
|
+
module IndicatorHub
|
|
6
|
+
module Indicators
|
|
7
|
+
# Williams %R (WR).
|
|
8
|
+
# WR is a momentum indicator that measures overbought and oversold levels.
|
|
9
|
+
class WR
|
|
10
|
+
# Calculates the Williams %R.
|
|
11
|
+
# @param data [Array<Hash>] Array of OHLCV hashes.
|
|
12
|
+
# @param period [Integer] The WR period (default: 14).
|
|
13
|
+
# @return [Array<Float, nil>] The calculated Williams %R values.
|
|
14
|
+
def self.calculate(data, period: 14)
|
|
15
|
+
output = []
|
|
16
|
+
|
|
17
|
+
data.each_with_index do |val, i|
|
|
18
|
+
if i < period - 1
|
|
19
|
+
output << nil
|
|
20
|
+
next
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
slice = data[(i - period + 1)..i]
|
|
24
|
+
highest_high = slice.map { |d| d[:high] }.max
|
|
25
|
+
lowest_low = slice.map { |d| d[:low] }.min
|
|
26
|
+
|
|
27
|
+
# WR = (highest_high - close) / (highest_high - lowest_low) * -100
|
|
28
|
+
wr = (highest_high - val[:close]) / (highest_high - lowest_low).to_f * -100
|
|
29
|
+
output << wr
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
output
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# typed: true
|
|
3
|
+
|
|
4
|
+
require "sorbet-runtime"
|
|
5
|
+
|
|
6
|
+
module IndicatorHub
|
|
7
|
+
# Data structure for handling OHLCV and series data.
|
|
8
|
+
# Provides normalization and sorting capabilities for financial time series data.
|
|
9
|
+
class Series
|
|
10
|
+
extend T::Sig
|
|
11
|
+
|
|
12
|
+
# @return [Array<T.any(Hash, Numeric)>] The raw input data
|
|
13
|
+
sig { returns(T::Array[T.any(T::Hash[T.untyped, T.untyped], Numeric)]) }
|
|
14
|
+
attr_reader :data
|
|
15
|
+
|
|
16
|
+
# Initializes a new Series object.
|
|
17
|
+
#
|
|
18
|
+
# @param data [Array<T.any(Hash, Numeric)>] The input data, either an array of numbers or hashes
|
|
19
|
+
sig { params(data: T.any(T::Array[T.any(T::Hash[T.untyped, T.untyped], Numeric)], T::Hash[T.untyped, T.untyped], Numeric)).void }
|
|
20
|
+
def initialize(data)
|
|
21
|
+
@data = Array(data)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Normalizes input data into an array of floats.
|
|
25
|
+
#
|
|
26
|
+
# @param field [Symbol, String] The field to extract from the hash data (default: :close)
|
|
27
|
+
# @return [Array<Float>] An array of floating point numbers extracted from the data
|
|
28
|
+
sig { params(field: T.any(Symbol, String)).returns(T::Array[Float]) }
|
|
29
|
+
def to_a(field: :close)
|
|
30
|
+
data.map do |v|
|
|
31
|
+
if v.is_a?(Numeric)
|
|
32
|
+
v.to_f
|
|
33
|
+
elsif v.is_a?(Hash)
|
|
34
|
+
(v[field] || v[field.to_sym] || v[field.to_s] || 0.0).to_f
|
|
35
|
+
else
|
|
36
|
+
0.0
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Normalizes input data into an array of OHLCV hashes.
|
|
42
|
+
#
|
|
43
|
+
# @return [Array<Hash{Symbol => Float}>] An array of hashes containing open, high, low, close, and volume
|
|
44
|
+
sig { returns(T::Array[T::Hash[Symbol, Float]]) }
|
|
45
|
+
def to_ohlc
|
|
46
|
+
data.map do |v|
|
|
47
|
+
if v.is_a?(Hash)
|
|
48
|
+
{
|
|
49
|
+
open: (v[:open] || v["open"] || 0.0).to_f,
|
|
50
|
+
high: (v[:high] || v["high"] || 0.0).to_f,
|
|
51
|
+
low: (v[:low] || v["low"] || 0.0).to_f,
|
|
52
|
+
close: (v[:close] || v["close"] || 0.0).to_f,
|
|
53
|
+
volume: (v[:volume] || v["volume"] || 0.0).to_f
|
|
54
|
+
}
|
|
55
|
+
elsif v.is_a?(Numeric)
|
|
56
|
+
{
|
|
57
|
+
open: v.to_f,
|
|
58
|
+
high: v.to_f,
|
|
59
|
+
low: v.to_f,
|
|
60
|
+
close: v.to_f,
|
|
61
|
+
volume: 0.0
|
|
62
|
+
}
|
|
63
|
+
else
|
|
64
|
+
{ open: 0.0, high: 0.0, low: 0.0, close: 0.0, volume: 0.0 }
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Returns chronologically sorted data if it has date or time fields.
|
|
70
|
+
#
|
|
71
|
+
# @return [Array<T.any(Hash, Numeric)>] The sorted data
|
|
72
|
+
sig { returns(T::Array[T.any(T::Hash[T.untyped, T.untyped], Numeric)]) }
|
|
73
|
+
def sorted_data
|
|
74
|
+
return data unless data.first.is_a?(Hash) && (data.first[:date] || data.first["date"] || data.first[:date_time] || data.first["date_time"])
|
|
75
|
+
|
|
76
|
+
data.sort_by { |v| v[:date] || v["date"] || v[:date_time] || v["date_time"] }
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module IndicatorHub
|
|
4
|
+
# Optional adapter for high-performance TA-Lib calculations
|
|
5
|
+
class TALibAdapter
|
|
6
|
+
def self.available?
|
|
7
|
+
@available ||= begin
|
|
8
|
+
require "talib_ffi"
|
|
9
|
+
true
|
|
10
|
+
rescue LoadError
|
|
11
|
+
false
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def self.sma(_data, _period)
|
|
16
|
+
return nil unless available?
|
|
17
|
+
|
|
18
|
+
# Example: TALib.sma(data, period) - exact method depends on talib_ffi API
|
|
19
|
+
# For now, this is a placeholder to show where TA-Lib logic would go
|
|
20
|
+
nil
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# typed: true
|
|
3
|
+
|
|
4
|
+
require "sorbet-runtime"
|
|
5
|
+
|
|
6
|
+
module IndicatorHub
|
|
7
|
+
# Robust validation for technical analysis data and options.
|
|
8
|
+
module Validation
|
|
9
|
+
extend T::Sig
|
|
10
|
+
|
|
11
|
+
# Error class for validation failures.
|
|
12
|
+
class Error < StandardError; end
|
|
13
|
+
|
|
14
|
+
# Validates that the provided options are within the allowed set.
|
|
15
|
+
# @param options [Hash] The options to validate.
|
|
16
|
+
# @param valid_options [Array<Symbol>] The list of allowed option keys.
|
|
17
|
+
# @raise [Validation::Error] if an invalid option is found.
|
|
18
|
+
sig { params(options: T::Hash[Symbol, T.untyped], valid_options: T::Array[Symbol]).void }
|
|
19
|
+
def self.validate_options(options, valid_options)
|
|
20
|
+
raise Error, "Options must be a hash." unless options.is_a?(Hash)
|
|
21
|
+
|
|
22
|
+
invalid_keys = options.keys - valid_options
|
|
23
|
+
return if invalid_keys.empty?
|
|
24
|
+
|
|
25
|
+
raise Error, "Invalid options: #{invalid_keys.join(", ")}. Valid options are: #{valid_options.join(", ")}"
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Validates that all data points in the numeric array are numbers.
|
|
29
|
+
# @param data [Array<Numeric>] The data to validate.
|
|
30
|
+
# @raise [Validation::Error] if non-numeric data is found.
|
|
31
|
+
sig { params(data: T::Array[T.untyped]).void }
|
|
32
|
+
def self.validate_numeric_data(data)
|
|
33
|
+
return if data.all?(Numeric)
|
|
34
|
+
|
|
35
|
+
raise Error, "Invalid Data. Input must be numeric."
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Validates that the data set meets the minimum required length.
|
|
39
|
+
# @param data [Array] The data to validate.
|
|
40
|
+
# @param required_size [Integer] The minimum length required.
|
|
41
|
+
# @raise [Validation::Error] if the data is too short.
|
|
42
|
+
sig { params(data: T::Array[T.untyped], required_size: Integer).void }
|
|
43
|
+
def self.validate_length(data, required_size)
|
|
44
|
+
return unless data.size < required_size
|
|
45
|
+
|
|
46
|
+
raise Error, "Not enough data. Expected at least #{required_size}, got #{data.size}."
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|