From 4f3b04d443b0504850e865f50c535eea2f29d336 Mon Sep 17 00:00:00 2001 From: thoran Date: Mon, 15 Jun 2020 09:57:35 +1000 Subject: [PATCH 1/2] + Weighted Moving Average --- lib/technical_analysis.rb | 1 + .../helpers/stock_calculation.rb | 8 ++ lib/technical_analysis/indicators/wma.rb | 97 +++++++++++++++ .../technical_analysis/indicators/wma_spec.rb | 115 ++++++++++++++++++ 4 files changed, 221 insertions(+) create mode 100644 lib/technical_analysis/indicators/wma.rb create mode 100644 spec/technical_analysis/indicators/wma_spec.rb diff --git a/lib/technical_analysis.rb b/lib/technical_analysis.rb index f4368e8..e6b0e41 100644 --- a/lib/technical_analysis.rb +++ b/lib/technical_analysis.rb @@ -40,4 +40,5 @@ require 'technical_analysis/indicators/vi' require 'technical_analysis/indicators/vpt' require 'technical_analysis/indicators/vwap' +require 'technical_analysis/indicators/wma' require 'technical_analysis/indicators/wr' diff --git a/lib/technical_analysis/helpers/stock_calculation.rb b/lib/technical_analysis/helpers/stock_calculation.rb index 6da25da..1314fb5 100644 --- a/lib/technical_analysis/helpers/stock_calculation.rb +++ b/lib/technical_analysis/helpers/stock_calculation.rb @@ -21,5 +21,13 @@ def self.ema(current_value, data, period, prev_value) end end + def self.wma(data) + intermediate_values = [] + data.each_with_index do |datum, i| + intermediate_values << datum * (i + 1)/(data.size * (data.size + 1)/2).to_f + end + intermediate_values.sum + end + end end diff --git a/lib/technical_analysis/indicators/wma.rb b/lib/technical_analysis/indicators/wma.rb new file mode 100644 index 0000000..6e51de8 --- /dev/null +++ b/lib/technical_analysis/indicators/wma.rb @@ -0,0 +1,97 @@ +module TechnicalAnalysis + # Weighted Moving Average + class Wma < Indicator + + # Returns the symbol of the technical indicator + # + # @return [String] A string of the symbol of the technical indicator + def self.indicator_symbol + "wma" + end + + # Returns the name of the technical indicator + # + # @return [String] A string of the name of the technical indicator + def self.indicator_name + "Weighted Moving Average" + end + + # Returns an array of valid keys for options for this technical indicator + # + # @return [Array] An array of keys as symbols for valid options for this technical indicator + def self.valid_options + %i(period price_key date_time_key) + end + + # Validates the provided options for this technical indicator + # + # @param options [Hash] The options for the technical indicator to be validated + # + # @return [Boolean] Returns true if options are valid or raises a ValidationError if they're not + def self.validate_options(options) + Validation.validate_options(options, valid_options) + end + + # Calculates the minimum number of observations needed to calculate the technical indicator + # + # @param options [Hash] The options for the technical indicator + # + # @return [Integer] Returns the minimum number of observations needed to calculate the technical + # indicator based on the options provided + def self.min_data_size(period: 30, **params) + period.to_i + end + + # Calculates the weighted moving average (WMA) for the data over the given period + # https://en.wikipedia.org/wiki/Moving_average#Weighted_moving_average + # + # @param data [Array] Array of hashes with keys (:date_time, :value) + # @param period [Integer] The given period to calculate the WMA + # @param price_key [Symbol] The hash key for the price data. Default :value + # @param date_time_key [Symbol] The hash key for the date time data. Default :date_time + # + # @return [Array] An array of WmaValue instances + def self.calculate(data, period: 30, price_key: :value, date_time_key: :date_time) + period = period.to_i + price_key = price_key.to_sym + date_time_key = date_time_key.to_sym + Validation.validate_numeric_data(data, price_key) + Validation.validate_length(data, min_data_size(period: period)) + Validation.validate_date_time_key(data, date_time_key) + data = data.sort_by { |row| row[date_time_key] } + output = [] + period_values = [] + previous_wma = nil + data.each do |v| + period_values << v[price_key] + if period_values.size == period + wma = StockCalculation.wma(period_values) + output << WmaValue.new(date_time: v[date_time_key], wma: wma) + period_values.shift + end + end + output.sort_by(&:date_time).reverse + end + + end + + # The value class to be returned by calculations + class WmaValue + + # @return [String] the date_time of the obversation as it was provided + attr_accessor :date_time + + # @return [Float] the wma calculation value + attr_accessor :wma + + def initialize(date_time: nil, wma: nil) + @date_time = date_time + @wma = wma + end + + # @return [Hash] the attributes as a hash + def to_hash + { date_time: @date_time, wma: @wma } + end + end +end diff --git a/spec/technical_analysis/indicators/wma_spec.rb b/spec/technical_analysis/indicators/wma_spec.rb new file mode 100644 index 0000000..8c3bf2c --- /dev/null +++ b/spec/technical_analysis/indicators/wma_spec.rb @@ -0,0 +1,115 @@ +require 'technical-analysis' +require 'spec_helper' + +describe 'Indicators' do + describe "WMA" do + input_data = SpecHelper.get_test_data(:close, date_time_key: :timestep) + indicator = TechnicalAnalysis::Wma + + describe 'Weighted Moving Average' do + it 'Calculates WMA (5 day)' do + output = indicator.calculate(input_data, period: 5, price_key: :close, date_time_key: :timestep) + normalized_output = output.map(&:to_hash) + + expected_output = [ + {:date_time=>"2019-01-09T00:00:00.000Z", :wma=>150.13666666666666}, + {:date_time=>"2019-01-08T00:00:00.000Z", :wma=>148.83666666666667}, + {:date_time=>"2019-01-07T00:00:00.000Z", :wma=>148.856}, + {:date_time=>"2019-01-04T00:00:00.000Z", :wma=>150.36866666666666}, + {:date_time=>"2019-01-03T00:00:00.000Z", :wma=>152.29733333333334}, + {:date_time=>"2019-01-02T00:00:00.000Z", :wma=>157.248}, + {:date_time=>"2018-12-31T00:00:00.000Z", :wma=>156.216}, + {:date_time=>"2018-12-28T00:00:00.000Z", :wma=>154.77666666666667}, + {:date_time=>"2018-12-27T00:00:00.000Z", :wma=>153.88066666666666}, + {:date_time=>"2018-12-26T00:00:00.000Z", :wma=>153.32733333333334}, + {:date_time=>"2018-12-24T00:00:00.000Z", :wma=>153.02733333333333}, + {:date_time=>"2018-12-21T00:00:00.000Z", :wma=>157.31466666666665}, + {:date_time=>"2018-12-20T00:00:00.000Z", :wma=>161.28533333333334}, + {:date_time=>"2018-12-19T00:00:00.000Z", :wma=>164.164}, + {:date_time=>"2018-12-18T00:00:00.000Z", :wma=>166.23666666666665}, + {:date_time=>"2018-12-17T00:00:00.000Z", :wma=>166.75333333333333}, + {:date_time=>"2018-12-14T00:00:00.000Z", :wma=>168.35733333333332}, + {:date_time=>"2018-12-13T00:00:00.000Z", :wma=>169.64866666666666}, + {:date_time=>"2018-12-12T00:00:00.000Z", :wma=>169.368}, + {:date_time=>"2018-12-11T00:00:00.000Z", :wma=>170.21}, + {:date_time=>"2018-12-10T00:00:00.000Z", :wma=>172.28799999999998}, + {:date_time=>"2018-12-07T00:00:00.000Z", :wma=>174.64133333333334}, + {:date_time=>"2018-12-06T00:00:00.000Z", :wma=>178.102}, + {:date_time=>"2018-12-04T00:00:00.000Z", :wma=>179.90066666666667}, + {:date_time=>"2018-12-03T00:00:00.000Z", :wma=>180.87933333333334}, + {:date_time=>"2018-11-30T00:00:00.000Z", :wma=>178.46800000000002}, + {:date_time=>"2018-11-29T00:00:00.000Z", :wma=>177.71733333333333}, + {:date_time=>"2018-11-28T00:00:00.000Z", :wma=>176.4586666666667}, + {:date_time=>"2018-11-27T00:00:00.000Z", :wma=>174.47266666666667}, + {:date_time=>"2018-11-26T00:00:00.000Z", :wma=>175.49466666666666}, + {:date_time=>"2018-11-23T00:00:00.000Z", :wma=>177.65066666666667}, + {:date_time=>"2018-11-21T00:00:00.000Z", :wma=>181.858}, + {:date_time=>"2018-11-20T00:00:00.000Z", :wma=>185.23666666666668}, + {:date_time=>"2018-11-19T00:00:00.000Z", :wma=>189.56533333333334}, + {:date_time=>"2018-11-16T00:00:00.000Z", :wma=>191.488}, + {:date_time=>"2018-11-15T00:00:00.000Z", :wma=>191.58333333333334}, + {:date_time=>"2018-11-14T00:00:00.000Z", :wma=>193.524}, + {:date_time=>"2018-11-13T00:00:00.000Z", :wma=>198.54466666666667}, + {:date_time=>"2018-11-12T00:00:00.000Z", :wma=>202.52466666666666}, + {:date_time=>"2018-11-09T00:00:00.000Z", :wma=>206.35266666666666}, + {:date_time=>"2018-11-08T00:00:00.000Z", :wma=>206.948}, + {:date_time=>"2018-11-07T00:00:00.000Z", :wma=>207.11866666666666}, + {:date_time=>"2018-11-06T00:00:00.000Z", :wma=>207.39666666666665}, + {:date_time=>"2018-11-05T00:00:00.000Z", :wma=>210.37}, + {:date_time=>"2018-11-02T00:00:00.000Z", :wma=>214.78}, + {:date_time=>"2018-11-01T00:00:00.000Z", :wma=>217.81466666666665}, + {:date_time=>"2018-10-31T00:00:00.000Z", :wma=>215.7746666666667}, + {:date_time=>"2018-10-30T00:00:00.000Z", :wma=>214.60333333333335}, + {:date_time=>"2018-10-29T00:00:00.000Z", :wma=>215.91400000000002}, + {:date_time=>"2018-10-26T00:00:00.000Z", :wma=>218.13866666666667}, + {:date_time=>"2018-10-25T00:00:00.000Z", :wma=>219.21066666666667}, + {:date_time=>"2018-10-24T00:00:00.000Z", :wma=>218.864}, + {:date_time=>"2018-10-23T00:00:00.000Z", :wma=>220.494}, + {:date_time=>"2018-10-22T00:00:00.000Z", :wma=>219.53866666666667}, + {:date_time=>"2018-10-19T00:00:00.000Z", :wma=>219.05733333333333}, + {:date_time=>"2018-10-18T00:00:00.000Z", :wma=>219.20933333333335}, + {:date_time=>"2018-10-17T00:00:00.000Z", :wma=>220.35333333333335}, + {:date_time=>"2018-10-16T00:00:00.000Z", :wma=>219.452}, + {:date_time=>"2018-10-15T00:00:00.000Z", :wma=>218.54533333333336} + ] + + expect(normalized_output).to eq(expected_output) + end + + it "Throws exception if not enough data" do + expect {indicator.calculate(input_data, period: input_data.size+1, price_key: :close)}.to raise_exception(TechnicalAnalysis::Validation::ValidationError) + end + + it 'Returns the symbol' do + indicator_symbol = indicator.indicator_symbol + expect(indicator_symbol).to eq('wma') + end + + it 'Returns the name' do + indicator_name = indicator.indicator_name + expect(indicator_name).to eq('Weighted Moving Average') + end + + it 'Returns the valid options' do + valid_options = indicator.valid_options + expect(valid_options).to eq(%i(period price_key date_time_key)) + end + + it 'Validates options' do + valid_options = { period: 22, price_key: :close, date_time_key: :timestep } + options_validated = indicator.validate_options(valid_options) + expect(options_validated).to eq(true) + end + + it 'Throws exception for invalid options' do + invalid_options = { test: 10 } + expect { indicator.validate_options(invalid_options) }.to raise_exception(TechnicalAnalysis::Validation::ValidationError) + end + + it 'Calculates minimum data size' do + options = { period: 4 } + expect(indicator.min_data_size(**options)).to eq(4) + end + end + end +end From 4088e63fbdb4384e3049ce3dd05e89951185a7f7 Mon Sep 17 00:00:00 2001 From: thoran Date: Mon, 29 Jun 2020 22:46:42 +1000 Subject: [PATCH 2/2] + empty lines as per review --- lib/technical_analysis/indicators/wma.rb | 3 +++ 1 file changed, 3 insertions(+) diff --git a/lib/technical_analysis/indicators/wma.rb b/lib/technical_analysis/indicators/wma.rb index 6e51de8..fa6a5c9 100644 --- a/lib/technical_analysis/indicators/wma.rb +++ b/lib/technical_analysis/indicators/wma.rb @@ -58,10 +58,13 @@ def self.calculate(data, period: 30, price_key: :value, date_time_key: :date_tim Validation.validate_numeric_data(data, price_key) Validation.validate_length(data, min_data_size(period: period)) Validation.validate_date_time_key(data, date_time_key) + data = data.sort_by { |row| row[date_time_key] } + output = [] period_values = [] previous_wma = nil + data.each do |v| period_values << v[price_key] if period_values.size == period