├── .ruby-version ├── .rspec ├── possibilities.png ├── lib ├── quant │ ├── version.rb │ ├── indicators.rb │ ├── mixins │ │ ├── filters.rb │ │ ├── moving_averages.rb │ │ ├── hilbert_transform.rb │ │ ├── simple_moving_average.rb │ │ ├── direction.rb │ │ ├── stochastic.rb │ │ ├── butterworth_filters.rb │ │ ├── exponential_moving_average.rb │ │ ├── weighted_moving_average.rb │ │ ├── fisher_transform.rb │ │ ├── super_smoother.rb │ │ └── functions.rb │ ├── pivots_source.rb │ ├── indicators │ │ ├── pivots │ │ │ ├── classic.rb │ │ │ ├── murrey.rb │ │ │ ├── fibbonacci.rb │ │ │ ├── atr.rb │ │ │ ├── bollinger.rb │ │ │ ├── keltner.rb │ │ │ ├── traditional.rb │ │ │ ├── donchian.rb │ │ │ ├── guppy.rb │ │ │ ├── demark.rb │ │ │ ├── woodie.rb │ │ │ ├── camarilla.rb │ │ │ └── pivot.rb │ │ ├── dominant_cycles │ │ │ ├── half_period.rb │ │ │ ├── differential.rb │ │ │ ├── homodyne.rb │ │ │ └── phase_accumulator.rb │ │ ├── ping.rb │ │ ├── indicator_point.rb │ │ ├── frama.rb │ │ ├── rocket_rsi.rb │ │ ├── snr.rb │ │ ├── cci.rb │ │ ├── rsi.rb │ │ ├── roofing.rb │ │ ├── atr.rb │ │ ├── mesa.rb │ │ ├── ema.rb │ │ ├── adx.rb │ │ └── decycler.rb │ ├── config.rb │ ├── statistics │ │ └── correlation.rb │ ├── experimental.rb │ ├── dominant_cycles_source.rb │ ├── settings.rb │ ├── errors.rb │ ├── indicators_sources.rb │ ├── ticks │ │ ├── serializers │ │ │ ├── spot.rb │ │ │ └── ohlc.rb │ │ └── spot.rb │ ├── indicators_registry.rb │ ├── time_period.rb │ ├── time_methods.rb │ └── asset.rb └── quantitative.rb ├── .yardopts ├── bin ├── setup └── console ├── .gitignore ├── spec ├── lib │ ├── quant │ │ ├── mixins │ │ │ ├── filters_spec.rb │ │ │ ├── moving_averages_spec.rb │ │ │ ├── simple_moving_average_spec.rb │ │ │ ├── exponential_moving_average_spec.rb │ │ │ ├── hilbert_transform_spec.rb │ │ │ ├── functions_spec.rb │ │ │ ├── stochastic_spec.rb │ │ │ ├── weighted_moving_average_spec.rb │ │ │ ├── butterworth_filters_spec.rb │ │ │ └── super_smoother_spec.rb │ │ ├── experimental_spec.rb │ │ ├── indicators │ │ │ ├── dominant_cycles │ │ │ │ ├── dominant_cycle_spec.rb │ │ │ │ ├── acr_spec.rb │ │ │ │ ├── band_pass_spec.rb │ │ │ │ ├── homodyne_spec.rb │ │ │ │ ├── differential_spec.rb │ │ │ │ ├── phase_accumulator_spec.rb │ │ │ │ └── half_period_spec.rb │ │ │ ├── indicator_point_spec.rb │ │ │ ├── pivots │ │ │ │ ├── demark_spec.rb │ │ │ │ ├── classic_spec.rb │ │ │ │ ├── donchian_spec.rb │ │ │ │ ├── atr_spec.rb │ │ │ │ ├── murrey_spec.rb │ │ │ │ ├── keltner_spec.rb │ │ │ │ ├── fibbonacci_spec.rb │ │ │ │ ├── woodie_spec.rb │ │ │ │ ├── guppy_spec.rb │ │ │ │ ├── bollinger_spec.rb │ │ │ │ ├── pivot_spec.rb │ │ │ │ ├── camarilla_spec.rb │ │ │ │ └── traditional_spec.rb │ │ │ ├── ping_spec.rb │ │ │ ├── cci_spec.rb │ │ │ ├── frama_spec.rb │ │ │ ├── atr_spec.rb │ │ │ ├── adx_spec.rb │ │ │ ├── decycler_spec.rb │ │ │ ├── mama_spec.rb │ │ │ ├── mesa_spec.rb │ │ │ ├── ema_spec.rb │ │ │ ├── rocket_rsi_spec.rb │ │ │ ├── rsi_spec.rb │ │ │ └── roofing_spec.rb │ │ ├── dominant_cycles_source_spec.rb │ │ ├── indicators_sources_spec.rb │ │ ├── pivots_source_spec.rb │ │ ├── settings │ │ │ └── indicators_spec.rb │ │ ├── ticks │ │ │ ├── serializers │ │ │ │ ├── spot_spec.rb │ │ │ │ └── ohlc_spec.rb │ │ │ └── tick_spec.rb │ │ ├── asset_class_spec.rb │ │ ├── time_methods_spec.rb │ │ ├── attrs_spec.rb │ │ ├── config_spec.rb │ │ └── asset_spec.rb │ └── quantitative_spec.rb ├── fixtures │ └── series │ │ ├── DEUCES-sample.txt │ │ ├── IBM-19990104_19990107.txt │ │ ├── AAPL-19990104_19990107.txt │ │ └── AAPL-19990104_19990107.json ├── spec_helper.rb └── performance │ └── optimal_compute.rb ├── Guardfile ├── .rubocop.yml ├── Gemfile ├── DISCLAIMER.md ├── .github └── workflows │ └── ci.yml ├── LICENSE ├── Rakefile └── quantitative.gemspec /.ruby-version: -------------------------------------------------------------------------------- 1 | 3.3.0 2 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --format documentation 2 | --color 3 | --require spec_helper 4 | -------------------------------------------------------------------------------- /possibilities.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mwlang/quantitative/HEAD/possibilities.png -------------------------------------------------------------------------------- /lib/quant/version.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Quant 4 | VERSION = "0.3.3" 5 | end 6 | -------------------------------------------------------------------------------- /.yardopts: -------------------------------------------------------------------------------- 1 | --protected 2 | --no-private 3 | lib/quant/**/*.rb 4 | - 5 | CODE_OF_CONDUCT.md 6 | DISCLAIMER.md 7 | LICENSE 8 | -------------------------------------------------------------------------------- /lib/quant/indicators.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Quant 4 | module Indicators 5 | end 6 | end 7 | -------------------------------------------------------------------------------- /bin/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | IFS=$'\n\t' 4 | set -vx 5 | 6 | bundle install 7 | 8 | # Do any other automated setup that you need to do here 9 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle/ 2 | /.yardoc 3 | /_yardoc/ 4 | /coverage/ 5 | /doc/ 6 | /pkg/ 7 | /spec/reports/ 8 | /tmp/ 9 | 10 | # rspec failure tracking 11 | .rspec_status 12 | *.gem 13 | -------------------------------------------------------------------------------- /spec/lib/quant/mixins/filters_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "high_pass_filters_spec" 4 | require_relative "butterworth_filters_spec" 5 | require_relative "universal_filters_spec" 6 | -------------------------------------------------------------------------------- /Guardfile: -------------------------------------------------------------------------------- 1 | guard :rspec, cmd: "rspec" do 2 | watch(%r{^spec/.+_spec\.rb$}) 3 | watch(%r{^spec/lib/.+_spec\.rb$}) 4 | watch(%r{^lib/(.+)\.rb$}) { |m| "spec/lib/#{m[1]}_spec.rb" } 5 | watch("spec/spec_helper.rb") { "spec" } 6 | end 7 | -------------------------------------------------------------------------------- /spec/lib/quant/mixins/moving_averages_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "weighted_moving_average_spec" 4 | require_relative "simple_moving_average_spec" 5 | require_relative "exponential_moving_average_spec" 6 | -------------------------------------------------------------------------------- /lib/quant/mixins/filters.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Quant 4 | module Mixins 5 | module Filters 6 | include Mixins::HighPassFilters 7 | include Mixins::ButterworthFilters 8 | include Mixins::UniversalFilters 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /lib/quant/mixins/moving_averages.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Quant 4 | module Mixins 5 | module MovingAverages 6 | include WeightedMovingAverage 7 | include SimpleMovingAverage 8 | include ExponentialMovingAverage 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /lib/quant/pivots_source.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Quant 4 | class PivotsSource 5 | def initialize(indicator_source:) 6 | @indicator_source = indicator_source 7 | indicator_source.define_indicator_accessors(indicator_source: self) 8 | end 9 | 10 | private 11 | 12 | def indicator(indicator_class) 13 | @indicator_source[indicator_class] 14 | end 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /lib/quant/mixins/hilbert_transform.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Quant 4 | module Mixins 5 | module HilbertTransform 6 | def hilbert_transform(source, period:) 7 | [0.0962 * p0.send(source), 8 | 0.5769 * p2.send(source), 9 | -0.5769 * p(4).send(source), 10 | -0.0962 * p(6).send(source),].sum * ((0.075 * period) + 0.54) 11 | end 12 | end 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | require "bundler/setup" 5 | require "quantitative" 6 | 7 | # You can add fixtures and/or initialization code here to make experimenting 8 | # with your gem easier. You can also use a different console, if you like. 9 | 10 | # (If you use this, don't forget to add pry to your Gemfile!) 11 | # require "pry" 12 | # Pry.start 13 | 14 | require "irb" 15 | IRB.start(__FILE__) 16 | -------------------------------------------------------------------------------- /spec/fixtures/series/DEUCES-sample.txt: -------------------------------------------------------------------------------- 1 | {"ot":"1999-01-04T13:30:00Z","ct":"1999-01-04T21:00:00Z","o":2,"h":4,"l":2,"c":4,"ac":1,"bv":100,"tv":1000} 2 | {"ot":"1999-01-05T13:30:00Z","ct":"1999-01-05T21:00:00Z","o":4,"h":8,"l":4,"c":8,"ac":1,"bv":200,"tv":2000} 3 | {"ot":"1999-01-06T13:30:00Z","ct":"1999-01-06T21:00:00Z","o":8,"h":16,"l":8,"c":16,"ac":1,"bv":300,"tv":3000} 4 | {"ot":"1999-01-07T13:30:00Z","ct":"1999-01-07T21:00:00Z","o":16,"h":32,"l":16,"c":32,"ac":1,"bv":400,"tv":4000} 5 | -------------------------------------------------------------------------------- /spec/lib/quantitative_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe Quant do 4 | it "has a version number" do 5 | expect(Quant::VERSION).not_to be nil 6 | end 7 | 8 | it "gives a current time" do 9 | expect(Quant.current_time).to be_within(5).of(Time.now) 10 | end 11 | 12 | describe ".config" do 13 | it { expect(Quant.config).to be_a(Quant::Config::Config) } 14 | end 15 | 16 | describe ".config.indicators" do 17 | it { expect(Quant.config.indicators).to be_a(Quant::Settings::Indicators) } 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | inherit_gem: 2 | relaxed-rubocop: .rubocop.yml 3 | 4 | AllCops: 5 | TargetRubyVersion: 3.2 6 | Exclude: 7 | - 'spec/performance/*.rb' 8 | 9 | Style/AccessorGrouping: 10 | Enabled: false 11 | 12 | Style/StringLiterals: 13 | Enabled: true 14 | EnforcedStyle: double_quotes 15 | 16 | Style/StringLiteralsInInterpolation: 17 | Enabled: true 18 | EnforcedStyle: double_quotes 19 | 20 | Layout/LineLength: 21 | Max: 120 22 | 23 | Lint/AmbiguousBlockAssociation: 24 | AllowedMethods: 25 | - change 26 | - not_change 27 | 28 | -------------------------------------------------------------------------------- /spec/lib/quant/experimental_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe Quant::Experimental do 4 | subject { described_class } 5 | 6 | it { is_expected.to respond_to(:tracker) } 7 | 8 | it "emits a message" do 9 | expect(described_class).to receive(:rspec_defined?).and_return(false) 10 | expect { Quant.experimental("foo") }.to output(/EXPERIMENTAL/).to_stdout 11 | end 12 | 13 | it "does not emit a message if rspec is defined" do 14 | expect { Quant.experimental("foo") }.not_to output(/EXPERIMENTAL/).to_stdout 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /spec/fixtures/series/IBM-19990104_19990107.txt: -------------------------------------------------------------------------------- 1 | {"ot":"1999-01-04T13:30:00Z","ct":"1999-01-04T21:00:00Z","o":92.5,"h":93.25,"l":90.75,"c":91.5,"ac":91.5,"bv":8149600,"tv":8149600} 2 | {"ot":"1999-01-05T13:30:00Z","ct":"1999-01-05T21:00:00Z","o":91.5,"h":94.9375,"l":91.40625,"c":94.8125,"ac":94.8125,"bv":9907600,"tv":9907600} 3 | {"ot":"1999-01-06T13:30:00Z","ct":"1999-01-06T21:00:00Z","o":95.15625,"h":96.375,"l":94.25,"c":94.375,"ac":94.375,"bv":9539600,"tv":9539600} 4 | {"ot":"1999-01-07T13:30:00Z","ct":"1999-01-07T21:00:00Z","o":93.96875,"h":96.1875,"l":93.5,"c":95.09375,"ac":95.09375,"bv":8306800,"tv":8306800} 5 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source "https://rubygems.org" 4 | 5 | # Specify your gem"s dependencies in quantitative.gemspec 6 | gemspec 7 | 8 | gem "rake", "~> 13.0" 9 | 10 | gem "rspec", "~> 3.0" 11 | 12 | gem "rubocop", "~> 1.21" 13 | gem "rubocop-rspec" 14 | gem "relaxed-rubocop" 15 | 16 | gem "debug" 17 | gem "guard-rspec", "~> 4.7" 18 | gem "yard", "~> 0.9" 19 | gem "benchmark-ips", "~> 2.9" 20 | 21 | gem "rspec-github" 22 | 23 | # Test coverage and profiling 24 | gem "simplecov" 25 | gem "simplecov-cobertura" 26 | gem "stackprof", require: false 27 | gem "test-prof", require: false 28 | gem "vernier", require: false 29 | gem "ruby-prof", require: false -------------------------------------------------------------------------------- /lib/quant/indicators/pivots/classic.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Quant 4 | module Indicators 5 | module Pivots 6 | class Classic < Pivot 7 | register name: :classic 8 | 9 | def compute_midpoint 10 | p0.midpoint = smoothed_average_midpoint 11 | end 12 | 13 | def compute_bands 14 | p0.h1 = p0.midpoint * 2.0 - p0.avg_low 15 | p0.l1 = p0.midpoint * 2.0 - p0.avg_high 16 | 17 | p0.h2 = p0.midpoint + p0.avg_range 18 | p0.l2 = p0.midpoint - p0.avg_range 19 | 20 | p0.h3 = p0.midpoint + 2.0 * p0.avg_range 21 | p0.l3 = p0.midpoint - 2.0 * p0.avg_range 22 | end 23 | end 24 | end 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /lib/quant/indicators/dominant_cycles/half_period.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Quant 4 | module Indicators 5 | module DominantCycles 6 | # This dominant cycle indicator is based on the half period 7 | # that is the midpoint of the `min_period` and `max_period` 8 | # configured in the `Quant.config.indicators` object. 9 | # Effectively providing a static, arbitrarily set period. 10 | class HalfPeriodPoint < Quant::Indicators::IndicatorPoint 11 | attribute :period, default: :half_period 12 | end 13 | 14 | class HalfPeriod < DominantCycle 15 | register name: :half_period 16 | 17 | def compute 18 | # No-Op 19 | end 20 | end 21 | end 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /lib/quant/indicators/pivots/murrey.rb: -------------------------------------------------------------------------------- 1 | 2 | module Quant 3 | module Indicators 4 | module Pivots 5 | class Murrey < Pivot 6 | register name: :murrey 7 | 8 | def multiplier 9 | 0.125 10 | end 11 | 12 | def compute_midpoint 13 | p0.input = (p0.highest - p0.lowest) * multiplier 14 | p0.midpoint = p0.lowest + (p0.input * 4.0) 15 | end 16 | 17 | MURREY_SERIES = [1.0, 2.0, 3.0, 4.0, 5.0, 6.0].freeze 18 | 19 | def compute_bands 20 | MURREY_SERIES.each_with_index do |ratio, index| 21 | p0[index + 1] = p0.midpoint + p0.input * ratio 22 | p0[-index - 1] = p0.midpoint - p0.input * ratio 23 | end 24 | end 25 | end 26 | end 27 | end 28 | end -------------------------------------------------------------------------------- /lib/quant/indicators/ping.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Quant 4 | module Indicators 5 | # A simple point used primarily to test the indicator system in unit tests. 6 | # It has a simple computation that just sets the pong value to the input value 7 | # and increments the compute_count by 1 each time compute is called. 8 | # Sometimes you just gotta play ping pong to win. 9 | class PingPoint < IndicatorPoint 10 | attribute :pong 11 | attribute :compute_count, default: 0 12 | end 13 | 14 | # A simple idicator used primarily to test the indicator system 15 | class Ping < Indicator 16 | register name: :ping 17 | 18 | def compute 19 | p0.pong = input 20 | p0.compute_count += 1 21 | end 22 | end 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /spec/fixtures/series/AAPL-19990104_19990107.txt: -------------------------------------------------------------------------------- 1 | {"ot":"1999-01-04T13:30:00Z","ct":"1999-01-04T21:00:00Z","o":0.376116067171097,"h":0.377232134342194,"l":0.357142865657806,"c":0.368303567171097,"ac":0.368303567171097,"bv":952884800,"tv":952884800} 2 | {"ot":"1999-01-05T13:30:00Z","ct":"1999-01-05T21:00:00Z","o":0.37444195151329,"h":0.392299115657806,"l":0.37053570151329,"c":0.38671875,"ac":0.38671875,"bv":1410113600,"tv":1410113600} 3 | {"ot":"1999-01-06T13:30:00Z","ct":"1999-01-06T21:00:00Z","o":0.39397320151329,"h":0.39397320151329,"l":0.366071432828903,"c":0.372767865657806,"ac":0.372767865657806,"bv":1348569600,"tv":1348569600} 4 | {"ot":"1999-01-07T13:30:00Z","ct":"1999-01-07T21:00:00Z","o":0.377232134342194,"h":0.40234375,"l":0.376116067171097,"c":0.40178570151329,"ac":0.40178570151329,"bv":1429019200,"tv":1429019200} 5 | -------------------------------------------------------------------------------- /lib/quant/mixins/simple_moving_average.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Quant 4 | module Mixins 5 | module SimpleMovingAverage 6 | using Quant 7 | 8 | # Computes the Simple Moving Average (SMA) of the given period. 9 | # 10 | # @param source [Symbol] the source of the data points to be used in the calculation. 11 | # @param period [Integer] the number of elements to compute the SMA over. 12 | # @return [Float] the simple moving average of the period. 13 | def simple_moving_average(source, period:) 14 | raise ArgumentError, "source must be a Symbol" unless source.is_a?(Symbol) 15 | 16 | values.last(period).map { |value| value.send(source) }.mean 17 | end 18 | alias sma simple_moving_average 19 | end 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /spec/fixtures/series/AAPL-19990104_19990107.json: -------------------------------------------------------------------------------- 1 | [{"ot":"1999-01-04T13:30:00Z","ct":"1999-01-04T21:00:00Z","o":0.376116067171097,"h":0.377232134342194,"l":0.357142865657806,"c":0.368303567171097,"ac":0.368303567171097,"bv":952884800,"tv":952884800}, 2 | {"ot":"1999-01-05T13:30:00Z","ct":"1999-01-05T21:00:00Z","o":0.37444195151329,"h":0.392299115657806,"l":0.37053570151329,"c":0.38671875,"ac":0.38671875,"bv":1410113600,"tv":1410113600}, 3 | {"ot":"1999-01-06T13:30:00Z","ct":"1999-01-06T21:00:00Z","o":0.39397320151329,"h":0.39397320151329,"l":0.366071432828903,"c":0.372767865657806,"ac":0.372767865657806,"bv":1348569600,"tv":1348569600}, 4 | {"ot":"1999-01-07T13:30:00Z","ct":"1999-01-07T21:00:00Z","o":0.377232134342194,"h":0.40234375,"l":0.376116067171097,"c":0.40178570151329,"ac":0.40178570151329,"bv":1429019200,"tv":1429019200}] 5 | -------------------------------------------------------------------------------- /spec/lib/quant/indicators/dominant_cycles/dominant_cycle_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe Quant::Indicators::DominantCycles::DominantCycle do 4 | let(:filename) { fixture_filename("DEUCES-sample.txt", :series) } 5 | let(:series) { Quant::Series.from_file(filename:, symbol: "DEUCES", interval: "1d") } 6 | let(:source) { :oc2 } 7 | let(:test_class) do 8 | Class.new(described_class) do 9 | def compute_period 10 | p0.period = 10 11 | end 12 | end 13 | end 14 | 15 | subject { test_class.new(series:, source:) } 16 | 17 | it { is_expected.to be_a(described_class) } 18 | it { expect(subject.series.size).to eq(4) } 19 | it { expect(subject.ticks.size).to eq(4) } 20 | it { expect(subject.p0).to eq subject.values[-1] } 21 | it { expect(subject.t0).to eq subject.ticks[-1] } 22 | end 23 | -------------------------------------------------------------------------------- /lib/quant/config.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Quant 4 | module Config 5 | class Config 6 | attr_reader :indicators 7 | 8 | def initialize 9 | @indicators = Settings::Indicators.defaults 10 | end 11 | 12 | def apply_indicator_settings(**settings) 13 | @indicators.apply_settings(**settings) 14 | end 15 | end 16 | 17 | def self.default! 18 | @config = Config.new 19 | end 20 | 21 | def self.config 22 | @config ||= Config.new 23 | end 24 | end 25 | 26 | module_function 27 | 28 | def config 29 | Config.config 30 | end 31 | 32 | def default_configuration! 33 | Config.default! 34 | end 35 | 36 | def configure_indicators(**settings) 37 | config.apply_indicator_settings(**settings) 38 | yield config.indicators if block_given? 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /lib/quant/statistics/correlation.rb: -------------------------------------------------------------------------------- 1 | module Quant 2 | module Statistics 3 | class Correlation 4 | attr_accessor :length, :sx, :sy, :sxx, :sxy, :syy 5 | 6 | def initialize 7 | @length = 0.0 8 | @sx = 0.0 9 | @sy = 0.0 10 | @sxx = 0.0 11 | @sxy = 0.0 12 | @syy = 0.0 13 | end 14 | 15 | def add(x, y) 16 | @length += 1 17 | @sx += x 18 | @sy += y 19 | @sxx += x * x 20 | @sxy += x * y 21 | @syy += y * y 22 | end 23 | 24 | def devisor 25 | value = (length * sxx - sx**2) * (length * syy - sy**2) 26 | value.zero? ? 1.0 : value 27 | end 28 | 29 | def coefficient 30 | (length * sxy - sx * sy) / Math.sqrt(devisor) 31 | rescue Math::DomainError 32 | 0.0 33 | end 34 | end 35 | end 36 | end 37 | 38 | -------------------------------------------------------------------------------- /DISCLAIMER.md: -------------------------------------------------------------------------------- 1 | ## DISCLAIMER 2 | 3 | This library is intended for educational and informational purposes only. It is 4 | not intended to provide trading or investment advice. Trading cryptocurrency, 5 | stocks and forex involves substantial risk of loss and is not suitable for everyone. 6 | 7 | The information provided by this library should not be construed as an endorsement, 8 | recommendation, or solicitation to buy or sell any security or financial instrument. 9 | Users of this library are solely responsible for their own trading decisions and 10 | should seek independent financial advice if they have any questions or concerns. 11 | 12 | Past performance is not necessarily indicative of future results. By using this 13 | library, you agree that the developers and contributors will not be liable for any 14 | losses or damages arising from your use of the library. Use at your own risk. -------------------------------------------------------------------------------- /lib/quant/indicators/pivots/fibbonacci.rb: -------------------------------------------------------------------------------- 1 | module Quant 2 | module Indicators 3 | module Pivots 4 | class Fibbonacci < Pivot 5 | register name: :fibbonacci 6 | 7 | FIBBONACCI_SERIES = [0.146, 0.236, 0.382, 0.5, 0.618, 0.786, 1.0, 1.146].freeze 8 | 9 | def compute_bands 10 | period_points(adaptive_period).tap do |period_points| 11 | highest = period_points.map(&:high_price).max 12 | lowest = period_points.map(&:low_price).min 13 | p0.range = highest - lowest 14 | p0.midpoint = (highest + lowest) * 0.5 15 | end 16 | 17 | FIBBONACCI_SERIES.each_with_index do |ratio, index| 18 | p0[index + 1] = p0.midpoint + ratio * p0.range 19 | p0[-index - 1] = p0.midpoint - ratio * p0.range 20 | end 21 | end 22 | end 23 | end 24 | end 25 | end -------------------------------------------------------------------------------- /lib/quant/experimental.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Quant 4 | # {Quant::Experimental} is an alert emitter for experimental code paths. 5 | # It will typically be used for new indicators or computations that are not yet 6 | # fully vetted or tested. 7 | module Experimental 8 | def self.tracker 9 | @tracker ||= {} 10 | end 11 | 12 | def self.rspec_defined? 13 | defined?("RSpec") 14 | end 15 | end 16 | 17 | module_function 18 | 19 | def experimental(message) 20 | return if Experimental.rspec_defined? 21 | return if Experimental.tracker[caller.first] 22 | 23 | Experimental.tracker[caller.first] = message 24 | 25 | calling_method = caller.first.scan(/`([^']*)/)[0][0] 26 | full_message = "EXPERIMENTAL: #{calling_method.inspect}: #{message}\nsource location: #{caller.first}" 27 | puts full_message 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: [ "main" ] 6 | pull_request: 7 | branches: [ "main" ] 8 | 9 | permissions: 10 | contents: read 11 | 12 | jobs: 13 | test: 14 | runs-on: ubuntu-latest 15 | strategy: 16 | matrix: 17 | ruby: 18 | # - '3.0' 19 | # - '3.1' 20 | - '3.2' 21 | - '3.3' 22 | 23 | 24 | name: RSpec tests ruby ${{ matrix.ruby }} 25 | steps: 26 | - name: Check out code 27 | uses: actions/checkout@v4 28 | - uses: ruby/setup-ruby@v1 29 | with: 30 | ruby-version: ${{ matrix.ruby }} 31 | bundler-cache: true 32 | 33 | - name: Run RSpec 34 | run: bundle exec rspec 35 | 36 | - name: Upload coverage reports to Codecov 37 | uses: codecov/codecov-action@v4.0.1 38 | with: 39 | token: ${{ secrets.CODECOV_TOKEN }} 40 | slug: mwlang/quantitative 41 | -------------------------------------------------------------------------------- /lib/quant/indicators/pivots/atr.rb: -------------------------------------------------------------------------------- 1 | module Quant 2 | module Indicators 3 | module Pivots 4 | class Atr < Pivot 5 | register name: :atr 6 | depends_on Indicators::Atr 7 | 8 | def atr_point 9 | series.indicators[source].atr.points[t0] 10 | end 11 | 12 | def scale 13 | 3.0 14 | end 15 | 16 | def atr_value 17 | atr_point.value * scale 18 | end 19 | 20 | def compute_midpoint 21 | p0.midpoint = smoothed_average_midpoint 22 | end 23 | 24 | ATR_SERIES = [0.236, 0.382, 0.500, 0.618, 0.786, 1.0].freeze 25 | 26 | def compute_bands 27 | ATR_SERIES.each_with_index do |ratio, index| 28 | offset = ratio * atr_value 29 | p0[index + 1] = p0.midpoint + offset 30 | p0[-index - 1] = p0.midpoint - offset 31 | end 32 | end 33 | end 34 | end 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /lib/quant/indicators/pivots/bollinger.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Quant 4 | module Indicators 5 | module Pivots 6 | class Bollinger < Pivot 7 | register name: :bollinger 8 | 9 | using Quant 10 | 11 | def compute_midpoint 12 | values = period_points(adaptive_half_period).map(&:input) 13 | alpha = bars_to_alpha(adaptive_half_period) 14 | 15 | p0.midpoint = alpha * values.mean + (1 - alpha) * p1.midpoint 16 | p0.std_dev = values.standard_deviation(p0.midpoint) 17 | end 18 | 19 | BOLLINGER_SERIES = [1.0, 1.5, 1.75, 2.0, 2.25, 2.5, 2.75, 3.0].freeze 20 | 21 | def compute_bands 22 | BOLLINGER_SERIES.each_with_index do |ratio, index| 23 | p0[index + 1] = p0.midpoint + ratio * p0.std_dev 24 | p0[-index - 1] = p0.midpoint - ratio * p0.std_dev 25 | end 26 | end 27 | end 28 | end 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /lib/quant/mixins/direction.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Quant 4 | module Mixins 5 | module Direction 6 | def direction(average, current) 7 | if average < current 8 | :up 9 | elsif average > current 10 | :down 11 | else 12 | :flat 13 | end 14 | end 15 | 16 | def dir_label(average, current) 17 | { up: "UP", flat: "--", down: "DN" }[direction(average, current)] 18 | end 19 | 20 | def up? 21 | direction == :up 22 | end 23 | 24 | def flat? 25 | direction == :flat 26 | end 27 | 28 | def down? 29 | direction == :down 30 | end 31 | 32 | def up_or_flat? 33 | up? || flat? 34 | end 35 | 36 | def down_or_flat? 37 | down? || flat? 38 | end 39 | 40 | def dir_label(colorize) 41 | dir_label(average, psn, colorize) 42 | end 43 | end 44 | end 45 | end 46 | -------------------------------------------------------------------------------- /spec/lib/quant/indicators/indicator_point_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe Quant::Indicators::IndicatorPoint do 4 | subject { described_class.new(indicator:, tick:, source:) } 5 | 6 | describe "attributes" do 7 | let(:indicator) { instance_double(Quant::Indicators::Indicator, min_period: 1) } 8 | let(:tick) { instance_double(Quant::Ticks::OHLC, oc2: 3.0, low_price: 1.0, high_price: 8.0, open_price: 2.0, volume: 100) } 9 | let(:source) { :oc2 } 10 | 11 | it { expect(subject.tick).to eq(tick) } 12 | it { expect(subject.source).to eq(source) } 13 | it { expect(subject.input).to eq(3.0) } 14 | it { expect(subject.to_h).to eq("in" => 3.0, "src" => :oc2) } 15 | it { expect(subject.min_period).to eq(1) } 16 | it { expect(subject.high_price).to eq(8.0) } 17 | it { expect(subject.low_price).to eq(1.0) } 18 | it { expect(subject.oc2).to eq(3.0) } 19 | it { expect(subject.volume).to eq(100) } 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /lib/quant/indicators/dominant_cycles/differential.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Quant 4 | module Indicators 5 | module DominantCycles 6 | # The Dual Differentiator algorithm computes the phase angle from the 7 | # analytic signal as the arctangent of the ratio of the imaginary 8 | # component to the real component. Further, the angular frequency 9 | # is defined as the rate change of phase. We can use these facts to 10 | # derive the cycle period. 11 | class Differential < DominantCycle 12 | register name: :differential 13 | 14 | def compute_period 15 | p0.ddd = (p0.q2 * (p0.i2 - p1.i2)) - (p0.i2 * (p0.q2 - p1.q2)) 16 | p0.inst_period = p0.ddd > 0.01 ? 6.2832 * (p0.i2**2 + p0.q2**2) / p0.ddd : 0.0 17 | 18 | constrain_period_magnitude_change 19 | constrain_period_bars 20 | p0.period = p0.inst_period.round(0).to_i 21 | end 22 | end 23 | end 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /lib/quant/indicators/pivots/keltner.rb: -------------------------------------------------------------------------------- 1 | module Quant 2 | module Indicators 3 | module Pivots 4 | class Keltner < Pivot 5 | register name: :keltner 6 | depends_on Indicators::Atr 7 | 8 | def atr_point 9 | series.indicators[source].atr.points[t0] 10 | end 11 | 12 | def scale 13 | 3.0 14 | end 15 | 16 | def compute_midpoint 17 | alpha = bars_to_alpha(min_period) 18 | p0.midpoint = alpha * p0.input + (1 - alpha) * p1.midpoint 19 | end 20 | 21 | KELTNER_SERIES = [0.236, 0.382, 0.500, 0.618, 0.786, 1.0].freeze 22 | 23 | def compute_bands 24 | atr_value = atr_point.value * scale 25 | 26 | KELTNER_SERIES.each_with_index do |ratio, index| 27 | offset = ratio * atr_value 28 | p0[index + 1] = p0.midpoint + offset 29 | p0[-index - 1] = p0.midpoint - offset 30 | end 31 | end 32 | end 33 | end 34 | end 35 | end -------------------------------------------------------------------------------- /lib/quant/mixins/stochastic.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Quant 4 | module Mixins 5 | module Stochastic 6 | using Quant 7 | 8 | # The Stochastic Oscillator is a momentum indicator that compares a particular 9 | # closing price of a security to a range of its prices over a certain 10 | # period of time. It was developed by George C. Lane in the 1950s. 11 | 12 | # The main idea behind the Stochastic Oscillator is that closing 13 | # prices should close near the same direction as the current trend. 14 | # In a market trending up, prices will likely close near their 15 | # high, and in a market trending down, prices close near their low. 16 | def stochastic(source, period:) 17 | subset = values.last(period).map{ |p| p.send(source) } 18 | 19 | lowest, highest = subset.minimum.to_f, subset.maximum.to_f 20 | return 0.0 if (highest - lowest).zero? 21 | 22 | 100.0 * (subset[-1] - lowest) / (highest - lowest) 23 | end 24 | end 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /spec/lib/quant/dominant_cycles_source_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe Quant::DominantCyclesSource do 4 | let(:series) do 5 | # 40 bar sine wave 6 | Quant::Series.new(symbol: "SINE", interval: "1d").tap do |series| 7 | 5.times do 8 | (0..39).each do |degree| 9 | radians = degree * 2 * Math::PI / 40 10 | series << 5.0 * Math.sin(radians) + 10.0 11 | end 12 | end 13 | end 14 | end 15 | let(:source) { :oc2 } 16 | let(:indicator_source) { Quant::IndicatorsSource.new(series:, source:) } 17 | 18 | subject { described_class.new(indicator_source:) } 19 | 20 | it { expect(subject.acr.values[-1].period).to eq(40) } 21 | it { expect(subject.band_pass.values[-1].period).to eq(40) } 22 | it { expect(subject.homodyne.values[-1].period).to eq(40) } 23 | 24 | it { expect(subject.differential.values[-1].period).to eq(41) } 25 | it { expect(subject.phase_accumulator.values[-1].period).to eq(41) } 26 | it { expect(subject.half_period.values[-1].period).to eq(29) } 27 | end 28 | -------------------------------------------------------------------------------- /spec/lib/quant/indicators_sources_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe Quant::IndicatorsSources do 4 | let(:filename) { fixture_filename("DEUCES-sample.txt", :series) } 5 | let(:series) { Quant::Series.from_file(filename:, symbol: "DEUCES", interval: "1d") } 6 | let(:source) { :oc2 } 7 | 8 | subject { described_class.new(series:) } 9 | 10 | it { is_expected.to be_a(described_class) } 11 | it { expect(subject[source]).to be_a Quant::IndicatorsSource } 12 | 13 | it "raises an error for an invalid source" do 14 | expect { subject[:invalid_source] }.to raise_error Quant::Errors::InvalidIndicatorSource 15 | end 16 | 17 | it "raises an error for a stringified valid source" do 18 | expect { subject["oc2"] }.to raise_error Quant::Errors::InvalidIndicatorSource 19 | end 20 | 21 | context 'oc2 as default source' do 22 | it { expect(subject[:oc2].ping.map(&:pong)).to eq [3.0, 6.0, 12.0, 24.0] } 23 | it { expect(subject.ping.map(&:pong)).to eq [3.0, 6.0, 12.0, 24.0] } 24 | it { expect(subject.ping.source).to eq :oc2 } 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /lib/quant/indicators/dominant_cycles/homodyne.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Quant 4 | module Indicators 5 | module DominantCycles 6 | # Homodyne means the signal is multiplied by itself. More precisely, 7 | # we want to multiply the signal of the current bar with the complex 8 | # value of the signal one bar ago 9 | class Homodyne < DominantCycle 10 | register name: :homodyne 11 | 12 | def compute_period 13 | p0.re = (p0.i2 * p1.i2) + (p0.q2 * p1.q2) 14 | p0.im = (p0.i2 * p1.q2) - (p0.q2 * p1.i2) 15 | 16 | p0.re = (0.2 * p0.re) + (0.8 * p1.re) 17 | p0.im = (0.2 * p0.im) + (0.8 * p1.im) 18 | 19 | p0.inst_period = 360.0 / rad2deg(Math.atan(p0.im / p0.re)) if (p0.im != 0) && (p0.re != 0) 20 | 21 | constrain_period_magnitude_change 22 | constrain_period_bars 23 | p0.mean_period = super_smoother :inst_period, previous: :mean_period, period: max_period 24 | p0.period = p0.mean_period.round(0).to_i 25 | end 26 | end 27 | end 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /spec/lib/quant/indicators/pivots/demark_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe Quant::Indicators::Pivots::Demark do 4 | let(:filename) { fixture_filename("DEUCES-sample.txt", :series) } 5 | let(:series) { Quant::Series.from_file(filename:, symbol: "DEUCES", interval: "1d") } 6 | let(:source) { :oc2 } 7 | 8 | subject { described_class.new(series:, source:) } 9 | 10 | it { is_expected.to be_a(described_class) } 11 | it { expect(subject.series.size).to eq(4) } 12 | it { expect(subject.ticks).to be_a(Array) } 13 | it { expect(subject.values.map{ |v| v.input.round(3) }).to eq([14.0, 23.648, 47.348, 95.779]) } 14 | 15 | context "bands" do 16 | it { expect(subject.values.map{ |v| v.h1.round(3) }).to eq([3.0, 4.449, 8.724, 17.792]) } 17 | it { expect(subject.values.map{ |v| v.midpoint.round(3) }).to eq([3.5, 4.862, 9.271, 18.968]) } 18 | it { expect(subject.values.map{ |v| v.h0.round(3) }).to eq(subject.values.map{ |v| v.midpoint.round(3) }) } 19 | it { expect(subject.values.map{ |v| v.l1.round(3) }).to eq([5.0, 7.087, 13.633, 27.863]) } 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2024 mwlang 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /spec/lib/quant/indicators/ping_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe Quant::Indicators::Ping do 4 | let(:filename) { fixture_filename("DEUCES-sample.txt", :series) } 5 | let(:series) { Quant::Series.from_file(filename:, symbol: "DEUCES", interval: "1d") } 6 | let(:source) { :oc2 } 7 | 8 | subject { described_class.new(series:, source:) } 9 | 10 | it { is_expected.to be_a(described_class) } 11 | it { expect(subject.series.size).to eq(4) } 12 | it { expect(subject.ticks).to be_a(Array) } 13 | it { expect(subject.ticks.first).to be_a(Quant::Ticks::Tick) } 14 | it { expect(subject.values.first).to be_a(Quant::Indicators::PingPoint) } 15 | it { expect(subject.ticks.size).to eq(4) } 16 | it { expect(subject.p0.pong).to eq 24 } 17 | it { expect(subject.p1.pong).to eq 12 } 18 | it { expect(subject.p2.pong).to eq 6 } 19 | it { expect(subject.p3.pong).to eq 3 } 20 | it { expect(subject.p1).to eq subject.values[-2] } 21 | it { expect(subject.p2).to eq subject.values[-3] } 22 | it { expect(subject.p3).to eq subject.values[-4] } 23 | it { expect(subject.values.map(&:compute_count)).to be_all 1 } 24 | end 25 | -------------------------------------------------------------------------------- /spec/lib/quant/pivots_source_spec.rb: -------------------------------------------------------------------------------- 1 | 2 | # frozen_string_literal: true 3 | 4 | RSpec.describe Quant::PivotsSource do 5 | let(:filename) { fixture_filename("DEUCES-sample.txt", :series) } 6 | let(:series) { Quant::Series.from_file(filename:, symbol: "DEUCES", interval: "1d") } 7 | let(:source) { :oc2 } 8 | let(:indicator_source) { Quant::IndicatorsSource.new(series:, source:) } 9 | 10 | subject { described_class.new(indicator_source:) } 11 | 12 | it { expect(subject.atr.band?(6)).to be_truthy } 13 | it { expect(subject.bollinger.band?(8)).to be_truthy } 14 | it { expect(subject.camarilla.band?(6)).to be_truthy } 15 | it { expect(subject.classic.band?(3)).to be_truthy } 16 | it { expect(subject.demark.band?(1)).to be_truthy } 17 | it { expect(subject.donchian.band?(3)).to be_truthy } 18 | it { expect(subject.fibbonacci.band?(7)).to be_truthy } 19 | it { expect(subject.guppy.band?(7)).to be_truthy } 20 | it { expect(subject.keltner.band?(6)).to be_truthy } 21 | it { expect(subject.murrey.band?(6)).to be_truthy } 22 | it { expect(subject.traditional.band?(3)).to be_truthy } 23 | it { expect(subject.woodie.band?(4)).to be_truthy } 24 | end 25 | -------------------------------------------------------------------------------- /spec/lib/quant/indicators/cci_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe Quant::Indicators::Cci do 4 | subject { described_class.new(series:, source:) } 5 | 6 | context "sine series" do 7 | let(:source) { :oc2 } 8 | let(:period) { 40 } 9 | let(:cycles) { 5 } 10 | let(:uniq_data_points) { cycles * period / cycles } # sine is cyclical, so we expect a few unique data points 11 | let(:series) do 12 | # period bar sine wave 13 | Quant::Series.new(symbol: "SINE", interval: "1d").tap do |series| 14 | cycles.times do 15 | (0...period).each do |degree| 16 | radians = degree * 2 * Math::PI / period 17 | series << 5.0 * Math.sin(radians) + 10.0 18 | end 19 | end 20 | end 21 | end 22 | 23 | it { expect(subject.series.size).to eq(cycles * period) } 24 | 25 | it { expect(subject.values.map(&:state).uniq).to eq([-1, 0, 1]) } 26 | 27 | it "is roughly evenly distributed" do 28 | states = subject.values.map(&:state).group_by(&:itself).transform_values(&:count) 29 | 30 | expect(states).to eq({ -1 => 52, 0 => 83, 1 => 65 }) 31 | end 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /spec/lib/quant/settings/indicators_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Quant 4 | RSpec.describe Settings::Indicators do 5 | context "all defaults" do 6 | subject { described_class.defaults } 7 | 8 | it { expect(subject.max_period).to eq Settings::MAX_PERIOD } 9 | it { expect(subject.min_period).to eq Settings::MIN_PERIOD } 10 | it { expect(subject.half_period).to eq Settings::HALF_PERIOD } 11 | it { expect(subject.pivot_kind).to eq Settings::PIVOT_KINDS.first } 12 | it { expect(subject.dominant_cycle_kind).to eq Settings::DOMINANT_CYCLE_KINDS.first } 13 | end 14 | 15 | context "custom settings" do 16 | subject do 17 | described_class.new( 18 | max_period: 10, 19 | min_period: 4, 20 | micro_period: 2, 21 | pivot_kind: :fibbonacci 22 | ) 23 | end 24 | 25 | it { expect(subject.max_period).to eq 10 } 26 | it { expect(subject.min_period).to eq 4 } 27 | it { expect(subject.half_period).to eq 7 } 28 | it { expect(subject.micro_period).to eq 2 } 29 | it { expect(subject.pivot_kind).to eq :fibbonacci } 30 | end 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /spec/lib/quant/indicators/dominant_cycles/acr_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe Quant::Indicators::DominantCycles::Acr do 4 | let(:filename) { fixture_filename("DEUCES-sample.txt", :series) } 5 | let(:series) { Quant::Series.from_file(filename:, symbol: "DEUCES", interval: "1d") } 6 | let(:source) { :oc2 } 7 | 8 | subject { described_class.new(series:, source:) } 9 | 10 | it { is_expected.to be_a(described_class) } 11 | it { expect(subject.series.size).to eq(4) } 12 | it { expect(subject.ticks.size).to eq(4) } 13 | it { expect(subject.p0).to eq subject.values[-1] } 14 | it { expect(subject.t0).to eq subject.ticks[-1] } 15 | 16 | context "sine series" do 17 | let(:series) do 18 | # 40 bar sine wave 19 | Quant::Series.new(symbol: "SINE", interval: "1d").tap do |series| 20 | 3.times do 21 | (0..39).each do |degree| 22 | radians = degree * 2 * Math::PI / 40 23 | series << 5.0 * Math.sin(radians) + 10.0 24 | end 25 | end 26 | end 27 | end 28 | 29 | it { expect(subject.series.size).to eq(120) } 30 | it { expect(subject.p0.period).to eq 40 } 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "bundler/gem_tasks" 4 | require "rspec/core/rake_task" 5 | 6 | RSpec::Core::RakeTask.new(:spec) 7 | 8 | require "rubocop/rake_task" 9 | 10 | RuboCop::RakeTask.new 11 | 12 | task default: %i[spec rubocop] 13 | 14 | namespace :gem do 15 | desc "Build the gem" 16 | task :build do 17 | sh "gem build quantitative.gemspec" 18 | end 19 | 20 | desc "Tag the release in git" 21 | task :tag do 22 | version = Quant::VERSION 23 | sh "git tag -a v#{version} -m 'Release #{version}'" 24 | sh "git push origin v#{version}" 25 | end 26 | 27 | desc "Install local *.gem file" 28 | task :install do 29 | sh "gem install quantitative-#{Quant::VERSION}.gem" 30 | end 31 | 32 | desc "Remove local *.gem files" 33 | task :clean do 34 | sh "rm -f quantitative-#{Quant::VERSION}.gem" 35 | end 36 | 37 | desc "Release #{Quant::VERSION} to rubygems.org" 38 | task release: [:build, :tag] do 39 | sh "gem push quantitative-#{Quant::VERSION}.gem" 40 | end 41 | 42 | desc "push #{Quant::VERSION} to rubygems.org" 43 | task push: [:build] do 44 | sh "gem push quantitative-#{Quant::VERSION}.gem" 45 | end 46 | end 47 | -------------------------------------------------------------------------------- /spec/lib/quant/indicators/dominant_cycles/band_pass_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe Quant::Indicators::DominantCycles::BandPass do 4 | let(:filename) { fixture_filename("DEUCES-sample.txt", :series) } 5 | let(:series) { Quant::Series.from_file(filename:, symbol: "DEUCES", interval: "1d") } 6 | let(:source) { :oc2 } 7 | 8 | subject { described_class.new(series:, source:) } 9 | 10 | it { is_expected.to be_a(described_class) } 11 | it { expect(subject.series.size).to eq(4) } 12 | it { expect(subject.ticks.size).to eq(4) } 13 | it { expect(subject.p0).to eq subject.values[-1] } 14 | it { expect(subject.t0).to eq subject.ticks[-1] } 15 | 16 | context "sine series" do 17 | let(:series) do 18 | # 40 bar sine wave 19 | Quant::Series.new(symbol: "SINE", interval: "1d").tap do |series| 20 | 3.times do 21 | (0..39).each do |degree| 22 | radians = degree * 2 * Math::PI / 40 23 | series << 5.0 * Math.sin(radians) + 10.0 24 | end 25 | end 26 | end 27 | end 28 | 29 | it { expect(subject.series.size).to eq(120) } 30 | it { expect(subject.p0.period).to eq 40 } 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /spec/lib/quant/indicators/dominant_cycles/homodyne_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe Quant::Indicators::DominantCycles::Homodyne do 4 | let(:filename) { fixture_filename("DEUCES-sample.txt", :series) } 5 | let(:series) { Quant::Series.from_file(filename:, symbol: "DEUCES", interval: "1d") } 6 | let(:source) { :oc2 } 7 | 8 | subject { described_class.new(series:, source:) } 9 | 10 | it { is_expected.to be_a(described_class) } 11 | it { expect(subject.series.size).to eq(4) } 12 | it { expect(subject.ticks.size).to eq(4) } 13 | it { expect(subject.p0).to eq subject.values[-1] } 14 | it { expect(subject.t0).to eq subject.ticks[-1] } 15 | 16 | context "sine series" do 17 | let(:series) do 18 | # 40 bar sine wave 19 | Quant::Series.new(symbol: "SINE", interval: "1d").tap do |series| 20 | 2.times do 21 | (0..39).each do |degree| 22 | radians = degree * 2 * Math::PI / 40 23 | series << 5.0 * Math.sin(radians) + 10.0 24 | end 25 | end 26 | end 27 | end 28 | 29 | it { expect(subject.series.size).to eq(80) } 30 | it { expect(subject.p0.period).to eq 40 } 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /spec/lib/quant/indicators/dominant_cycles/differential_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe Quant::Indicators::DominantCycles::Differential do 4 | let(:filename) { fixture_filename("DEUCES-sample.txt", :series) } 5 | let(:series) { Quant::Series.from_file(filename:, symbol: "DEUCES", interval: "1d") } 6 | let(:source) { :oc2 } 7 | 8 | subject { described_class.new(series:, source:) } 9 | 10 | it { is_expected.to be_a(described_class) } 11 | it { expect(subject.series.size).to eq(4) } 12 | it { expect(subject.ticks.size).to eq(4) } 13 | it { expect(subject.p0).to eq subject.values[-1] } 14 | it { expect(subject.t0).to eq subject.ticks[-1] } 15 | 16 | context "sine series" do 17 | let(:series) do 18 | # 40 bar sine wave 19 | Quant::Series.new(symbol: "SINE", interval: "1d").tap do |series| 20 | 2.times do 21 | (0..39).each do |degree| 22 | radians = degree * 2 * Math::PI / 40 23 | series << 5.0 * Math.sin(radians) + 10.0 24 | end 25 | end 26 | end 27 | end 28 | 29 | it { expect(subject.series.size).to eq(80) } 30 | it { expect(subject.p0.period).to eq 40 } 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /spec/lib/quant/indicators/dominant_cycles/phase_accumulator_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe Quant::Indicators::DominantCycles::PhaseAccumulator do 4 | let(:filename) { fixture_filename("DEUCES-sample.txt", :series) } 5 | let(:series) { Quant::Series.from_file(filename:, symbol: "DEUCES", interval: "1d") } 6 | let(:source) { :oc2 } 7 | 8 | subject { described_class.new(series:, source:) } 9 | 10 | it { is_expected.to be_a(described_class) } 11 | it { expect(subject.series.size).to eq(4) } 12 | it { expect(subject.ticks.size).to eq(4) } 13 | it { expect(subject.p0).to eq subject.values[-1] } 14 | it { expect(subject.t0).to eq subject.ticks[-1] } 15 | 16 | context "sine series" do 17 | let(:series) do 18 | # 40 bar sine wave 19 | Quant::Series.new(symbol: "SINE", interval: "1d").tap do |series| 20 | 5.times do 21 | (0..39).each do |degree| 22 | radians = degree * 2 * Math::PI / 40 23 | series << 5.0 * Math.sin(radians) + 10.0 24 | end 25 | end 26 | end 27 | end 28 | 29 | it { expect(subject.series.size).to eq(200) } 30 | it { expect(subject.p0.period).to eq 41 } 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /lib/quant/indicators/pivots/traditional.rb: -------------------------------------------------------------------------------- 1 | module Quant 2 | module Indicators 3 | module Pivots 4 | class Traditional < Pivot 5 | register name: :traditional 6 | 7 | def multiplier 8 | 2.0 9 | end 10 | 11 | # Pivot Point (PP) = (High + Low + Close) / 3 12 | def compute_midpoint 13 | p0.midpoint = p0.input 14 | end 15 | 16 | def compute_bands 17 | # Third Resistance (R3) = High + 2 × (PP - Low) 18 | p0.h3 = p0.high_price + (multiplier * (p0.midpoint - p0.low_price)) 19 | 20 | # Second Resistance (R2) = PP + (High - Low) 21 | p0.h2 = p0.midpoint + p0.range 22 | 23 | # First Resistance (R1) = (2 × PP) - Low 24 | p0.h1 = p0.midpoint * multiplier - p0.low_price 25 | 26 | # First Support (S1) = (2 × PP) - High 27 | p0.l1 = p0.midpoint * multiplier - p0.high_price 28 | 29 | # Second Support (S2) = PP - (High - Low) 30 | p0.l2 = p0.midpoint - p0.range 31 | 32 | # Third Support (S3) = Low - 2 × (High - PP) 33 | p0.l3 = p0.low_price - (multiplier * (p0.high_price - p0.midpoint)) 34 | end 35 | end 36 | end 37 | end 38 | end -------------------------------------------------------------------------------- /lib/quant/indicators/pivots/donchian.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Quant 4 | module Indicators 5 | module Pivots 6 | class Donchian < Pivot 7 | register name: :donchian 8 | 9 | def compute_midpoint 10 | p0.midpoint = (p0.high_price + p0.low_price) * 0.5 11 | end 12 | 13 | def compute_bands 14 | period_points(micro_period).tap do |period_points| 15 | p0.l1 = period_points.map(&:low_price).min 16 | p0.h1 = period_points.map(&:high_price).max 17 | end 18 | 19 | period_points(min_period).tap do |period_points| 20 | p0.l2 = period_points.map(&:low_price).min 21 | p0.h2 = period_points.map(&:high_price).max 22 | end 23 | 24 | period_points(half_period).tap do |period_points| 25 | p0.l3 = period_points.map(&:low_price).min 26 | p0.h3 = period_points.map(&:high_price).max 27 | end 28 | 29 | period_points(max_period).tap do |period_points| 30 | p0.l4 = period_points.map(&:low_price).min 31 | p0.h4 = period_points.map(&:high_price).max 32 | end 33 | end 34 | end 35 | end 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /lib/quant/dominant_cycles_source.rb: -------------------------------------------------------------------------------- 1 | 2 | module Quant 3 | # Dominant Cycles measure the primary cycle within a given range. By default, the library 4 | # is wired to look for cycles between 10 and 48 bars. These values can be adjusted by setting 5 | # the `min_period` and `max_period` configuration values in {Quant::Config}. 6 | # 7 | # Quant.configure_indicators(min_period: 8, max_period: 32) 8 | # 9 | # The default dominant cycle kind is the `half_period` filter. This can be adjusted by setting 10 | # the `dominant_cycle_kind` configuration value in {Quant::Config}. 11 | # 12 | # Quant.configure_indicators(dominant_cycle_kind: :band_pass) 13 | # 14 | # The purpose of these indicators is to compute the dominant cycle and underpin the various 15 | # indicators that would otherwise be setting an arbitrary lookback period. This makes the 16 | # indicators adaptive and auto-tuning to the market dynamics. Or so the theory goes! 17 | class DominantCyclesSource 18 | def initialize(indicator_source:) 19 | @indicator_source = indicator_source 20 | indicator_source.define_indicator_accessors(indicator_source: self) 21 | end 22 | 23 | private 24 | 25 | def indicator(indicator_class) 26 | @indicator_source[indicator_class] 27 | end 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /lib/quant/indicators/pivots/guppy.rb: -------------------------------------------------------------------------------- 1 | module Quant 2 | module Indicators 3 | module Pivots 4 | class Guppy < Pivot 5 | register name: :guppy 6 | 7 | def guppy_ema(period, band) 8 | return p0.input unless p1[band] 9 | 10 | alpha = bars_to_alpha(period) 11 | alpha * p0.input + (1 - alpha) * p1[band] 12 | end 13 | 14 | def compute_midpoint 15 | p0.midpoint = guppy_ema(3, 0) 16 | end 17 | 18 | # The short-term MAs are typically set at 3, 5, 8, 10, 12, and 15 periods. The 19 | # longer-term MAs are typically set at 30, 35, 40, 45, 50, and 60. 20 | def compute_bands 21 | p0[1] = guppy_ema(5, 1) 22 | p0[2] = guppy_ema(8, 2) 23 | p0[3] = guppy_ema(10, 3) 24 | p0[4] = guppy_ema(12, 4) 25 | p0[5] = guppy_ema(15, 5) 26 | p0[6] = guppy_ema(20, 6) 27 | p0[7] = guppy_ema(25, 7) 28 | 29 | p0[-1] = guppy_ema(30, -1) 30 | p0[-2] = guppy_ema(35, -2) 31 | p0[-3] = guppy_ema(40, -3) 32 | p0[-4] = guppy_ema(45, -4) 33 | p0[-5] = guppy_ema(50, -5) 34 | p0[-6] = guppy_ema(60, -6) 35 | p0[-7] = guppy_ema(120, -7) 36 | p0[-8] = guppy_ema(200, -8) 37 | end 38 | end 39 | end 40 | end 41 | end -------------------------------------------------------------------------------- /lib/quantitative.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "time" 4 | require "date" 5 | require "oj" 6 | require "csv" 7 | require "zeitwerk" 8 | 9 | lib_folder = File.expand_path(File.join(File.dirname(__FILE__))) 10 | quant_folder = File.join(lib_folder, "quant") 11 | 12 | # Explicitly require module functions since Zeitwerk isn't configured, yet. 13 | require_relative "quant/time_methods" 14 | require_relative "quant/config" 15 | require_relative "quant/experimental" 16 | module Quant 17 | include TimeMethods 18 | include Config 19 | include Experimental 20 | end 21 | Quantitative = Quant 22 | 23 | # Configure Zeitwerk to autoload the Quant module. 24 | loader = Zeitwerk::Loader.for_gem 25 | loader.push_dir(quant_folder, namespace: Quant) 26 | 27 | loader.inflector.inflect "ohlc" => "OHLC" 28 | loader.inflector.inflect "version" => "VERSION" 29 | 30 | loader.setup 31 | 32 | # Refinements aren't autoloaded by Zeitwerk, so we need to require them manually. 33 | # %w(refinements).each do |sub_folder| 34 | # Dir.glob(File.join(quant_folder, sub_folder, "**/*.rb")).each { |fn| require fn } 35 | # end 36 | 37 | refinements_folder = File.join(quant_folder, "refinements") 38 | indicators_folder = File.join(quant_folder, "indicators") 39 | 40 | loader.eager_load_dir(refinements_folder) 41 | loader.eager_load_dir(indicators_folder) 42 | -------------------------------------------------------------------------------- /lib/quant/settings.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Quant 4 | module Settings 5 | MAX_PERIOD = 48 6 | MIN_PERIOD = 10 7 | HALF_PERIOD = (MAX_PERIOD + MIN_PERIOD) / 2 8 | MICRO_PERIOD = 3 9 | 10 | PIVOT_KINDS = %i( 11 | pivot 12 | donchian 13 | fibbonacci 14 | woodie 15 | classic 16 | camarilla 17 | demark 18 | murrey 19 | keltner 20 | bollinger 21 | guppy 22 | atr 23 | ).freeze 24 | 25 | DOMINANT_CYCLE_KINDS = %i( 26 | half_period 27 | band_pass 28 | auto_correlation_reversal 29 | homodyne 30 | differential 31 | phase_accumulator 32 | ).freeze 33 | 34 | # ---- Risk Management Ratio Settings ---- 35 | # Risk Reward Breakeven Win Rate % 36 | # 50 1 98% 37 | # 10 1 91% 38 | # 5 1 83% 39 | # 3 1 75% 40 | # 2 1 67% 41 | # 1 1 50% 42 | # 1 2 33% 43 | # 1 3 25% 44 | # 1 5 17% 45 | # 1 10 9% 46 | # 1 50 2% 47 | PROFIT_TARGET_PCT = 0.03 48 | STOP_LOSS_PCT = 0.01 49 | end 50 | end 51 | -------------------------------------------------------------------------------- /lib/quant/indicators/indicator_point.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Quant 4 | module Indicators 5 | class IndicatorPoint 6 | include Quant::Attributes 7 | extend Forwardable 8 | 9 | attr_reader :indicator, :tick 10 | 11 | attribute :source, key: "src" 12 | attribute :input, key: "in" 13 | 14 | def initialize(indicator:, tick:, source:) 15 | @indicator = indicator 16 | @tick = tick 17 | @source = source 18 | @input = @tick.send(source) 19 | initialize_data_points 20 | end 21 | 22 | def_delegator :indicator, :series 23 | def_delegator :indicator, :min_period 24 | def_delegator :indicator, :max_period 25 | def_delegator :indicator, :half_period 26 | def_delegator :indicator, :micro_period 27 | def_delegator :indicator, :dominant_cycle_kind 28 | def_delegator :indicator, :pivot_kind 29 | 30 | def_delegator :tick, :high_price 31 | def_delegator :tick, :low_price 32 | def_delegator :tick, :close_price 33 | def_delegator :tick, :open_price 34 | def_delegator :tick, :volume 35 | def_delegator :tick, :trades 36 | 37 | def oc2 38 | tick.respond_to?(:oc2) ? tick.oc2 : tick.close_price 39 | end 40 | 41 | def initialize_data_points 42 | # No-Op - Override in subclass if needed. 43 | end 44 | end 45 | end 46 | end 47 | -------------------------------------------------------------------------------- /lib/quant/mixins/butterworth_filters.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Quant 4 | module Mixins 5 | module ButterworthFilters 6 | def two_pole_butterworth(source, period:, previous: :bw) 7 | raise ArgumentError, "source must be a Symbol" unless source.is_a?(Symbol) 8 | 9 | v0 = p0.send(source) 10 | 11 | v1 = 0.5 * (v0 + p1.send(source)) 12 | v2 = p1.send(previous) 13 | v3 = p2.send(previous) 14 | 15 | radians = Math.sqrt(2) * Math::PI / period 16 | a = Math.exp(-radians) 17 | b = 2 * a * Math.cos(radians) 18 | 19 | c2 = b 20 | c3 = -a**2 21 | c1 = 1.0 - c2 - c3 22 | 23 | (c1 * v1) + (c2 * v2) + (c3 * v3) 24 | end 25 | 26 | def three_pole_butterworth(source, period:, previous: :bw) 27 | raise ArgumentError, "source must be a Symbol" unless source.is_a?(Symbol) 28 | 29 | v0 = p0.send(source) 30 | v1 = p1.send(previous) 31 | v2 = p2.send(previous) 32 | v3 = p3.send(previous) 33 | 34 | radians = Math.sqrt(3) * Math::PI / period 35 | a = Math.exp(-radians) 36 | b = 2 * a * Math.cos(radians) 37 | c = a**2 38 | 39 | d4 = c**2 40 | d3 = -(c + (b * c)) 41 | d2 = b + c 42 | d1 = 1.0 - d2 - d3 - d4 43 | 44 | (d1 * v0) + (d2 * v1) + (d3 * v2) + (d4 * v3) 45 | end 46 | end 47 | end 48 | end 49 | -------------------------------------------------------------------------------- /spec/lib/quant/indicators/pivots/classic_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe Quant::Indicators::Pivots::Classic do 4 | let(:filename) { fixture_filename("DEUCES-sample.txt", :series) } 5 | let(:series) { Quant::Series.from_file(filename:, symbol: "DEUCES", interval: "1d") } 6 | let(:source) { :oc2 } 7 | 8 | subject { described_class.new(series:, source:) } 9 | 10 | it { is_expected.to be_a(described_class) } 11 | it { expect(subject.series.size).to eq(4) } 12 | it { expect(subject.ticks).to be_a(Array) } 13 | it { expect(subject.values.map(&:input)).to eq([3.0, 6.0, 12.0, 24.0]) } 14 | 15 | context "bands" do 16 | it { expect(subject.values.map{ |v| v.h3.round(3) }).to eq([3.529, 4.915, 9.239, 18.991]) } 17 | it { expect(subject.values.map{ |v| v.h2.round(3) }).to eq([3.265, 4.156, 7.069, 13.84]) } 18 | it { expect(subject.values.map{ |v| v.h1.round(3) }).to eq([4.0, 4.529, 6.532, 11.585]) } 19 | it { expect(subject.values.map{ |v| v.midpoint.round(3) }).to eq([3.0, 3.397, 4.899, 8.689]) } 20 | it { expect(subject.values.map{ |v| v.h0.round(3) }).to eq(subject.values.map{ |v| v.midpoint.round(3) }) } 21 | it { expect(subject.values.map{ |v| v.l1.round(3) }).to eq([2.0, 2.265, 3.266, 5.793]) } 22 | it { expect(subject.values.map{ |v| v.l2.round(3) }).to eq([2.735, 2.638, 2.729, 3.538]) } 23 | it { expect(subject.values.map{ |v| v.l3.round(3) }).to eq([2.471, 1.879, 0.559, -1.613]) } 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /spec/lib/quant/indicators/dominant_cycles/half_period_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe Quant::Indicators::DominantCycles::HalfPeriod do 4 | let(:filename) { fixture_filename("DEUCES-sample.txt", :series) } 5 | let(:series) { Quant::Series.from_file(filename:, symbol: "DEUCES", interval: "1d") } 6 | let(:source) { :oc2 } 7 | 8 | subject { described_class.new(series:, source:) } 9 | 10 | it { is_expected.to be_a(described_class) } 11 | it { expect(subject.series.size).to eq(4) } 12 | it { expect(subject.ticks.size).to eq(4) } 13 | it { expect(subject.p0).to eq subject.values[-1] } 14 | it { expect(subject.t0).to eq subject.ticks[-1] } 15 | 16 | context "sine series" do 17 | let(:series) do 18 | # 40 bar sine wave 19 | Quant::Series.new(symbol: "SINE", interval: "1d").tap do |series| 20 | 2.times do 21 | (0..39).each do |degree| 22 | radians = degree * 2 * Math::PI / 40 23 | series << 5.0 * Math.sin(radians) + 10.0 24 | end 25 | end 26 | end 27 | end 28 | 29 | it { expect(subject.series.size).to eq(80) } 30 | it { expect(subject.p0.period).to eq 29 } 31 | 32 | context "with an alternate configuration" do 33 | before do 34 | Quant.configure_indicators(max_period: 10, min_period: 4) 35 | end 36 | 37 | after(:all) { Quant.default_configuration! } 38 | 39 | it { expect(subject.p0.period).to eq 7 } 40 | end 41 | end 42 | end 43 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "debug" 4 | require "test-prof" 5 | 6 | TestProf.configure do |config| 7 | # the directory to put artifacts (reports) in ('tmp/test_prof' by default) 8 | config.output_dir = "tmp/test_prof" 9 | 10 | # use unique filenames for reports (by simply appending current timestamp) 11 | config.timestamps = true 12 | 13 | # color output 14 | config.color = true 15 | 16 | # where to write logs (defaults) 17 | config.output = $stdout 18 | end 19 | 20 | require "simplecov" 21 | require 'simplecov-cobertura' 22 | 23 | SimpleCov.start do 24 | add_filter "/spec/" 25 | formatter SimpleCov::Formatter::MultiFormatter.new([ 26 | SimpleCov::Formatter::HTMLFormatter, 27 | SimpleCov::Formatter::CoberturaFormatter 28 | ]) 29 | end 30 | 31 | require "quantitative" 32 | 33 | RSpec.configure do |config| 34 | # Enable flags like --only-failures and --next-failure 35 | config.example_status_persistence_file_path = ".rspec_status" 36 | 37 | # Disable RSpec exposing methods globally on `Module` and `main` 38 | config.disable_monkey_patching! 39 | config.filter_run_when_matching :focus 40 | 41 | config.expect_with :rspec do |c| 42 | c.syntax = :expect 43 | end 44 | end 45 | 46 | def fixture_path(sub_folder) 47 | File.join(File.expand_path(File.join(File.dirname(__FILE__), "fixtures")), sub_folder.to_s) 48 | end 49 | 50 | def fixture_filename(filename, sub_folder = nil) 51 | File.join fixture_path(sub_folder), filename 52 | end 53 | -------------------------------------------------------------------------------- /spec/lib/quant/indicators/pivots/donchian_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe Quant::Indicators::Pivots::Donchian do 4 | let(:filename) { fixture_filename("DEUCES-sample.txt", :series) } 5 | let(:series) { Quant::Series.from_file(filename:, symbol: "DEUCES", interval: "1d") } 6 | let(:source) { :oc2 } 7 | 8 | subject { described_class.new(series:, source:) } 9 | 10 | it { is_expected.to be_a(described_class) } 11 | it { expect(subject.series.size).to eq(4) } 12 | it { expect(subject.ticks).to be_a(Array) } 13 | it { expect(subject.values.map(&:input)).to eq([3.0, 6.0, 12.0, 24.0]) } 14 | 15 | # TODO: Need a longer series run to test this properly 16 | context "bands" do 17 | it { expect(subject.values.map{ |v| v.h4.round(3) }).to eq([4.0, 8.0, 16.0, 32.0]) } 18 | it { expect(subject.values.map{ |v| v.h3.round(3) }).to eq([4.0, 8.0, 16.0, 32.0]) } 19 | it { expect(subject.values.map{ |v| v.h2.round(3) }).to eq([4.0, 8.0, 16.0, 32.0]) } 20 | it { expect(subject.values.map{ |v| v.h1.round(3) }).to eq([4.0, 8.0, 16.0, 32.0]) } 21 | it { expect(subject.values.map{ |v| v.midpoint.round(3) }).to eq([3.0, 6.0, 12.0, 24.0]) } 22 | it { expect(subject.values.map{ |v| v.h0.round(3) }).to eq(subject.values.map{ |v| v.midpoint.round(3) }) } 23 | it { expect(subject.values.map{ |v| v.l1.round(3) }).to eq([2.0, 2.0, 2.0, 4.0]) } 24 | it { expect(subject.values.map{ |v| v.l2.round(3) }).to eq([2.0, 2.0, 2.0, 2.0]) } 25 | it { expect(subject.values.map{ |v| v.l3.round(3) }).to eq([2.0, 2.0, 2.0, 2.0]) } 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /spec/lib/quant/indicators/frama_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe Quant::Indicators::Frama do 4 | let(:filename) { fixture_filename("DEUCES-sample.txt", :series) } 5 | let(:series) { Quant::Series.from_file(filename:, symbol: "DEUCES", interval: "1d") } 6 | let(:source) { :oc2 } 7 | 8 | subject { described_class.new(series:, source:) } 9 | 10 | it { is_expected.to be_a(described_class) } 11 | it { expect(subject.series.size).to eq(4) } 12 | it { expect(subject.values.map(&:input)).to eq([3.0, 6.0, 12.0, 24.0]) } 13 | it { expect(subject.values.map{ |v| v.frama.round(3) }).to eq([3.0, 6.0, 12.0, 24.0]) } 14 | 15 | context "sine series" do 16 | let(:source) { :oc2 } 17 | let(:period) { 40 } 18 | let(:cycles) { 5 } 19 | let(:uniq_data_points) { cycles * period / cycles } # sine is cyclical, so we expect a few unique data points 20 | let(:series) do 21 | # period bar sine wave 22 | Quant::Series.new(symbol: "SINE", interval: "1d").tap do |series| 23 | cycles.times do 24 | (0...period).each do |degree| 25 | radians = degree * 2 * Math::PI / period 26 | series << 5.0 * Math.sin(radians) + 10.0 27 | end 28 | end 29 | end 30 | end 31 | 32 | it { expect(subject.series.size).to eq(cycles * period) } 33 | 34 | it { expect(subject.values.last(5).map{ |v| v.frama.round(3) }).to eq([9.232, 9.206, 9.185, 9.173, 9.174]) } 35 | it { expect(subject.values.first(5).map{ |v| v.frama.round(3) }).to eq([10.0, 10.782, 11.545, 12.27, 12.939]) } 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /lib/quant/mixins/exponential_moving_average.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Quant 4 | module Mixins 5 | module ExponentialMovingAverage 6 | # Computes the Exponential Moving Average (EMA) of the given period. 7 | # 8 | # The EMA computation is optimized to compute using just the last two 9 | # indicator data points and is expected to be called in each indicator's 10 | # `#compute` method for each iteration on the series. 11 | # 12 | # @param source [Symbol] the source of the data points to be used in the calculation. 13 | # @param previous [Symbol] the previous EMA value. 14 | # @param period [Integer] the number of elements to compute the EMA over. 15 | # @return [Float] the exponential moving average of the period. 16 | # @raise [ArgumentError] if the source is not a Symbol. 17 | # @example 18 | # def compute 19 | # p0.ema = exponential_moving_average(:close_price, period: 3) 20 | # end 21 | # 22 | # def compute 23 | # p0.ema = exponential_moving_average(:close_price, previous: :ema, period: 3) 24 | # end 25 | def exponential_moving_average(source, period:, previous: :ema) 26 | raise ArgumentError, "source must be a Symbol" unless source.is_a?(Symbol) 27 | raise ArgumentError, "previous must be a Symbol" unless previous.is_a?(Symbol) 28 | 29 | alpha = bars_to_alpha(period) 30 | (p0.send(source) * alpha) + (p1.send(previous) * (1.0 - alpha)) 31 | end 32 | alias ema exponential_moving_average 33 | end 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /quantitative.gemspec: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "lib/quant/version" 4 | 5 | Gem::Specification.new do |spec| 6 | spec.name = "quantitative" 7 | spec.version = Quant::VERSION 8 | spec.authors = ["Michael Lang"] 9 | spec.email = ["mwlang@cybrains.net"] 10 | 11 | spec.summary = "Quantitative and statistical tools written for Ruby 3.2+ for trading and finance." 12 | spec.description = spec.summary 13 | spec.homepage = "https://github.com/mwlang/quantitative" 14 | spec.license = "MIT" 15 | spec.required_ruby_version = ">= 3.2" 16 | 17 | spec.metadata["allowed_push_host"] = "https://rubygems.org" 18 | 19 | spec.metadata["homepage_uri"] = spec.homepage 20 | spec.metadata["source_code_uri"] = spec.homepage 21 | spec.metadata["changelog_uri"] = spec.homepage 22 | 23 | # Specify which files should be added to the gem when it is released. 24 | # The `git ls-files -z` loads the files in the RubyGem that have been added into git. 25 | spec.files = Dir.chdir(__dir__) do 26 | `git ls-files -z`.split("\x0").reject do |f| 27 | (f == __FILE__) || f.match(%r{\A(?:(?:bin|test|spec|features)/|\.(?:git|travis|circleci)|appveyor)}) 28 | end 29 | end 30 | spec.bindir = "exe" 31 | spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) } 32 | spec.require_paths = ["lib"] 33 | 34 | # Uncomment to register a new dependency of your gem 35 | spec.add_dependency "oj", "~> 3.10" 36 | spec.add_dependency "zeitwerk", "~> 2.6" 37 | 38 | # For more information and examples about making a new gem, check out our 39 | # guide at: https://bundler.io/guides/creating_gem.html 40 | end 41 | -------------------------------------------------------------------------------- /spec/lib/quant/indicators/atr_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe Quant::Indicators::Atr do 4 | let(:filename) { fixture_filename("DEUCES-sample.txt", :series) } 5 | let(:series) { Quant::Series.from_file(filename:, symbol: "DEUCES", interval: "1d") } 6 | let(:source) { :oc2 } 7 | 8 | subject { described_class.new(series:, source:) } 9 | 10 | it { is_expected.to be_a(described_class) } 11 | it { expect(subject.series.size).to eq(4) } 12 | it { expect(subject.values.map{ |v| v.tr.round(3) }).to eq([0.0, 4.0, 8.0, 16.0]) } 13 | it { expect(subject.values.map{ |v| v.value.round(3) }).to eq([0.0, 0.231, 0.95, 2.57]) } 14 | # it { expect(subject.values.map(&:crossed)).to all be(:unchanged) } 15 | 16 | context "sine series" do 17 | let(:period) { 40 } # period bar sine wave 18 | let(:cycles) { 4 } 19 | let(:series) do 20 | Quant::Series.new(symbol: "SINE", interval: "1d").tap do |series| 21 | cycles.times do 22 | (0...period).each do |degree| 23 | radians = degree * 2 * Math::PI / period 24 | series << 5.0 * Math.sin(radians) + 10.0 25 | end 26 | end 27 | end 28 | end 29 | 30 | it { expect(subject.series.size).to eq(160) } 31 | it { expect(subject.values.last(5).map{ |v| v.value.round(3) }).to eq([0.179, 0.237, 0.321, 0.419, 0.52]) } 32 | 33 | it "crosses 4x cycles" do 34 | grouped_crossings = subject.values.map(&:crossed).group_by(&:itself).transform_values(&:count) 35 | unchanged_count = period * cycles - cycles * 4 36 | expect(grouped_crossings).to eq({ down: cycles * 2, unchanged: unchanged_count, up: cycles * 2 }) 37 | end 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /lib/quant/errors.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Quant 4 | module Errors 5 | # {Error} is the base class for all errors in the Quant gem. 6 | # It is a subclass of the Ruby {StandardError}. 7 | class Error < StandardError; end 8 | 9 | # {InvalidInterval} is raised when attempting to instantiate an 10 | # {Quant::Interval} with an invalid value. 11 | class InvalidInterval < Error; end 12 | 13 | # {InvalidIndicatorSource} is raised when attempting to reference 14 | # an indicator through a source that has not been prepared, yet. 15 | class InvalidIndicatorSource < Error; end 16 | 17 | # {InvalidResolution} is raised when attempting to instantiate 18 | # an {Quant::Resolution} with a resolution value that has not been defined. 19 | class InvalidResolution < Error; end 20 | 21 | # {ArrayMaxSizeError} is raised when attempting to set the +max_size+ on 22 | # the refined {Array} class to an invalid value or when attempting to 23 | # redefine the +max_size+ on the refined {Array} class. 24 | class ArrayMaxSizeError < Error; end 25 | 26 | # {AssetClassError} is raised when attempting to instantiate a 27 | # {Quant::Asset} with an attribute that is not a valid {Quant::Asset} attribute. 28 | class AssetClassError < Error; end 29 | 30 | # {DuplicateAttributesKeyError} is raised when attempting to define an 31 | # attribute with a key that has already been defined. 32 | class DuplicateAttributesKeyError < Error; end 33 | 34 | # {DuplicateAttributesNameError} is raised when attempting to define an 35 | # attribute with a name that has already been defined. 36 | class DuplicateAttributesNameError < Error; end 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /lib/quant/indicators/frama.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Quant 4 | module Indicators 5 | class FramaPoint < IndicatorPoint 6 | attribute :frama, default: :input 7 | attribute :dimension, default: 0.0 8 | attribute :alpha, default: 0.0 9 | end 10 | 11 | # FRAMA (FRactal Adaptive Moving Average). A nonlinear moving average 12 | # is derived using the Hurst exponent. It rapidly follows significant 13 | # changes in price but becomes very flat in congestion zones so that 14 | # bad whipsaw trades can be eliminated. 15 | # 16 | # SOURCE: http://www.mesasoftware.com/papers/FRAMA.pdf 17 | class Frama < Indicator 18 | register name: :frama 19 | using Quant 20 | 21 | # The max_period is divided into two smaller, equal periods, so must be even 22 | def max_period 23 | @max_period ||= begin 24 | mp = super 25 | mp.even? ? mp : mp + 1 26 | end 27 | end 28 | 29 | def half_period 30 | max_period / 2 31 | end 32 | 33 | def compute 34 | pp = period_points(max_period).map(&:input) 35 | return if pp.size < max_period 36 | 37 | n3 = (pp.maximum - pp.minimum) / max_period 38 | 39 | ppn2 = pp.first(half_period) 40 | n2 = (ppn2.maximum - ppn2.minimum) / half_period 41 | 42 | ppn1 = pp.last(half_period) 43 | n1 = (ppn1.maximum - ppn1.minimum) / half_period 44 | 45 | p0.dimension = (Math.log(n1 + n2) - Math.log(n3)) / Math.log(2) 46 | p0.alpha = Math.exp(-4.6 * (p0.dimension - 1.0)).clamp(0.01, 1.0) 47 | p0.frama = (p0.alpha * p0.input) + ((1 - p0.alpha) * p1.frama) 48 | end 49 | end 50 | end 51 | end 52 | -------------------------------------------------------------------------------- /lib/quant/indicators/rocket_rsi.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Quant 4 | module Indicators 5 | class RocketRsiPoint < IndicatorPoint 6 | attribute :hp, default: 0.0 7 | 8 | attribute :delta, default: 0.0 9 | attribute :gain, default: 0.0 10 | attribute :loss, default: 0.0 11 | 12 | attribute :gains, default: 0.0 13 | attribute :losses, default: 0.0 14 | attribute :denom, default: 0.0 15 | 16 | attribute :inst_rsi, default: 0.5 17 | attribute :rsi, default: 0.0 18 | attribute :crosses, default: false 19 | end 20 | 21 | class RocketRsi < Indicator 22 | register name: :rocket_rsi 23 | 24 | def quarter_period 25 | half_period / 2 26 | end 27 | 28 | def half_period 29 | (dc_period / 2) - 1 30 | end 31 | 32 | def compute 33 | p0.hp = two_pole_butterworth :input, previous: :hp, period: quarter_period 34 | 35 | lp = p(half_period) 36 | p0.delta = p0.hp - lp.hp 37 | p0.delta > 0.0 ? p0.gain = p0.delta : p0.loss = p0.delta.abs 38 | 39 | period_points(half_period).tap do |period_points| 40 | p0.gains = period_points.map(&:gain).sum 41 | p0.losses = period_points.map(&:loss).sum 42 | end 43 | 44 | p0.denom = p0.gains + p0.losses 45 | 46 | if p0.denom.zero? 47 | p0.inst_rsi = p1.inst_rsi 48 | p0.rsi = p1.rsi 49 | else 50 | p0.inst_rsi = ((p0.gains - p0.losses) / p0.denom) 51 | p0.rsi = fisher_transform(p0.inst_rsi).clamp(-1.0, 1.0) 52 | end 53 | p0.crosses = (p0.rsi >= 0.0 && p1.rsi < 0.0) || (p0.rsi <= 0.0 && p1.rsi > 0.0) 54 | end 55 | end 56 | end 57 | end -------------------------------------------------------------------------------- /spec/lib/quant/indicators/pivots/atr_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe Quant::Indicators::Pivots::Atr do 4 | let(:filename) { fixture_filename("DEUCES-sample.txt", :series) } 5 | let(:series) { Quant::Series.from_file(filename:, symbol: "DEUCES", interval: "1d") } 6 | let(:source) { :oc2 } 7 | 8 | subject { described_class.new(series:, source:) } 9 | 10 | it { is_expected.to be_a(described_class) } 11 | it { expect(subject.series.size).to eq(4) } 12 | it { expect(subject.ticks).to be_a(Array) } 13 | it { expect(subject.values.map(&:input)).to eq([3.0, 6.0, 12.0, 24.0]) } 14 | 15 | context "bands" do 16 | it { expect(subject.values.map{ |v| v.h6.round(3) }).to eq([3.0, 4.09, 7.75, 16.4]) } 17 | it { expect(subject.values.map{ |v| v.h1.round(3) }).to eq([3.0, 3.56, 5.572, 10.509]) } 18 | it { expect(subject.values.map{ |v| v.midpoint.round(3) }).to eq([3.0, 3.397, 4.899, 8.689]) } 19 | it { expect(subject.values.map{ |v| v.h0.round(3) }).to eq(subject.values.map{ |v| v.midpoint.round(3) }) } 20 | it { expect(subject.values.map{ |v| v.l1.round(3) }).to eq([3.0, 3.234, 4.226, 6.869]) } 21 | it { expect(subject.values.map{ |v| v.l6.round(3) }).to eq([3.0, 2.704, 2.048, 0.978]) } 22 | end 23 | 24 | context "bands do not intersect each other" do 25 | %i[h6 h5 h4 h3 h2 h1 midpoint l1 l2 l3 l4 l5 l6].each_cons(2) do |above_band, below_band| 26 | it "band #{above_band.inspect} is above band #{below_band.inspect}" do 27 | compare_values = subject.values.drop(1) # first value is often zero, which isn't truthy for "positive?" 28 | expect(compare_values.map{ |v| v.send(above_band) - v.send(below_band) }).to all be_positive 29 | end 30 | end 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /spec/lib/quant/indicators/pivots/murrey_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe Quant::Indicators::Pivots::Murrey do 4 | let(:filename) { fixture_filename("DEUCES-sample.txt", :series) } 5 | let(:series) { Quant::Series.from_file(filename:, symbol: "DEUCES", interval: "1d") } 6 | let(:source) { :oc2 } 7 | 8 | subject { described_class.new(series:, source:) } 9 | 10 | it { is_expected.to be_a(described_class) } 11 | it { expect(subject.series.size).to eq(4) } 12 | it { expect(subject.ticks).to be_a(Array) } 13 | it { expect(subject.values.map(&:input)).to eq([0.0, 0.375, 1.125, 2.625]) } 14 | 15 | context "bands" do 16 | it { expect(subject.values.map{ |v| v.h6.round(3) }).to eq([3.0, 6.75, 14.25, 29.25]) } 17 | it { expect(subject.values.map{ |v| v.h1.round(3) }).to eq([3.0, 4.875, 8.625, 16.125]) } 18 | it { expect(subject.values.map{ |v| v.midpoint.round(3) }).to eq([3.0, 4.5, 7.5, 13.5]) } 19 | it { expect(subject.values.map{ |v| v.h0.round(3) }).to eq(subject.values.map{ |v| v.midpoint.round(3) }) } 20 | it { expect(subject.values.map{ |v| v.l1.round(3) }).to eq([3.0, 4.125, 6.375, 10.875]) } 21 | it { expect(subject.values.map{ |v| v.l6.round(3) }).to eq([3.0, 2.25, 0.75, -2.25]) } 22 | end 23 | 24 | context "bands do not intersect each other" do 25 | %i[h6 h5 h4 h3 h2 h1 midpoint l1 l2 l3 l4 l5 l6].each_cons(2) do |above_band, below_band| 26 | it "band #{above_band.inspect} is above band #{below_band.inspect}" do 27 | compare_values = subject.values.drop(1) # first value is often zero, which isn't truthy for "positive?" 28 | expect(compare_values.map{ |v| v.send(above_band) - v.send(below_band) }).to all be_positive 29 | end 30 | end 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /spec/lib/quant/indicators/pivots/keltner_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe Quant::Indicators::Pivots::Keltner do 4 | let(:filename) { fixture_filename("DEUCES-sample.txt", :series) } 5 | let(:series) { Quant::Series.from_file(filename:, symbol: "DEUCES", interval: "1d") } 6 | let(:source) { :oc2 } 7 | 8 | subject { described_class.new(series:, source:) } 9 | 10 | it { is_expected.to be_a(described_class) } 11 | it { expect(subject.series.size).to eq(4) } 12 | it { expect(subject.ticks).to be_a(Array) } 13 | it { expect(subject.values.map(&:input)).to eq([3.0, 6.0, 12.0, 24.0]) } 14 | 15 | context "bands" do 16 | it { expect(subject.values.map{ |v| v.h6.round(3) }).to eq([3.0, 4.238, 7.934, 16.233]) } 17 | it { expect(subject.values.map{ |v| v.h1.round(3) }).to eq([3.0, 3.709, 5.756, 10.342]) } 18 | it { expect(subject.values.map{ |v| v.midpoint.round(3) }).to eq([3.0, 3.545, 5.083, 8.522]) } 19 | it { expect(subject.values.map{ |v| v.h0.round(3) }).to eq(subject.values.map{ |v| v.midpoint.round(3) }) } 20 | it { expect(subject.values.map{ |v| v.l1.round(3) }).to eq([3.0, 3.382, 4.41, 6.702]) } 21 | it { expect(subject.values.map{ |v| v.l6.round(3) }).to eq([3.0, 2.853, 2.231, 0.811]) } 22 | end 23 | 24 | context "bands do not intersect each other" do 25 | %i[h6 h5 h4 h3 h2 h1 midpoint l1 l2 l3 l4 l5 l6].each_cons(2) do |above_band, below_band| 26 | it "band #{above_band.inspect} is above band #{below_band.inspect}" do 27 | compare_values = subject.values.drop(1) # first value is often zero, which isn't truthy for "positive?" 28 | expect(compare_values.map{ |v| v.send(above_band) - v.send(below_band) }).to all be_positive 29 | end 30 | end 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /spec/lib/quant/indicators/pivots/fibbonacci_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe Quant::Indicators::Pivots::Fibbonacci do 4 | let(:filename) { fixture_filename("DEUCES-sample.txt", :series) } 5 | let(:series) { Quant::Series.from_file(filename:, symbol: "DEUCES", interval: "1d") } 6 | let(:source) { :oc2 } 7 | 8 | subject { described_class.new(series:, source:) } 9 | 10 | it { is_expected.to be_a(described_class) } 11 | it { expect(subject.series.size).to eq(4) } 12 | it { expect(subject.ticks).to be_a(Array) } 13 | it { expect(subject.values.map(&:input)).to eq([3.0, 6.0, 12.0, 24.0]) } 14 | 15 | context "bands" do 16 | it { expect(subject.values.map{ |v| v.h6.round(3) }).to eq([4.572, 9.716, 20.004, 40.58]) } 17 | it { expect(subject.values.map{ |v| v.h1.round(3) }).to eq([3.292, 5.876, 11.044, 21.38]) } 18 | it { expect(subject.values.map{ |v| v.midpoint.round(3) }).to eq([3.0, 5.0, 9.0, 17.0]) } 19 | it { expect(subject.values.map{ |v| v.h0.round(3) }).to eq(subject.values.map{ |v| v.midpoint.round(3) }) } 20 | it { expect(subject.values.map{ |v| v.l1.round(3) }).to eq([2.708, 4.124, 6.956, 12.62]) } 21 | it { expect(subject.values.map{ |v| v.l6.round(3) }).to eq([1.428, 0.284, -2.004, -6.58]) } 22 | end 23 | 24 | context "bands do not intersect each other" do 25 | %i[h6 h5 h4 h3 h2 h1 midpoint l1 l2 l3 l4 l5 l6].each_cons(2) do |above_band, below_band| 26 | it "band #{above_band.inspect} is above band #{below_band.inspect}" do 27 | compare_values = subject.values.drop(1) # first value is often zero, which isn't truthy for "positive?" 28 | expect(compare_values.map{ |v| v.send(above_band) - v.send(below_band) }).to all be_positive 29 | end 30 | end 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /lib/quant/indicators/pivots/demark.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Quant 4 | module Indicators 5 | module Pivots 6 | # The value of X in the formula below depends on where the Close of the market is. 7 | # If Close = Open then X = (H + L + (C * 2)) 8 | 9 | # If Close > Open then X = ((H * 2) + L + C) 10 | 11 | # If Close < Open then X = (H + (L * 2) + C) 12 | 13 | # R1 = X / 2 - L 14 | # PP = X / 4 (this is not an official DeMark number but merely a reference point based on the calculation of X) 15 | # S1 = X / 2 - H 16 | class Demark < Pivot 17 | register name: :demark 18 | 19 | def averaging_period 20 | min_period / 2 21 | end 22 | 23 | def x_factor 24 | if t0.close_price == t0.open_price 25 | ((2.0 * t0.close_price) + p0.avg_high + p0.avg_low) 26 | elsif t0.close_price > t0.open_price 27 | ((2.0 * p0.avg_high) + p0.avg_low + t0.close_price) 28 | else 29 | ((2.0 * p0.avg_low) + p0.avg_high + t0.close_price) 30 | end 31 | end 32 | 33 | def compute_value 34 | p0.input = x_factor 35 | end 36 | 37 | def compute_midpoint 38 | p0.midpoint = p0.input / 4.0 39 | p0.midpoint = three_pole_super_smooth :midpoint, previous: :midpoint, period: averaging_period 40 | end 41 | 42 | def compute_bands 43 | p0.h1 = (p0.input / 2.0) - p0.avg_high 44 | p0.h1 = three_pole_super_smooth :h1, previous: :h1, period: averaging_period 45 | 46 | p0.l1 = (p0.input / 2.0) - p0.avg_low 47 | p0.l1 = three_pole_super_smooth :l1, previous: :l1, period: averaging_period 48 | end 49 | end 50 | end 51 | end 52 | end 53 | -------------------------------------------------------------------------------- /lib/quant/indicators_sources.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Quant 4 | # {Quant::IndicatorSources} pairs a collection of {Quant::Indicators::Indicator} with an input source. 5 | # This allows us to only compute indicators for the sources that are referenced at run-time. 6 | # Any source explicitly used at run-time will have its indicator computed and only those indicators 7 | # will be computed. 8 | class IndicatorsSources 9 | ALL_SOURCES = [ 10 | PRICE_SOURCES = %i[price open_price high_price low_price close_price].freeze, 11 | VOLUME_SOURCES = %i[volume base_volume target_volume].freeze, 12 | COMPUTED_SOURCES = %i[oc2 hl2 hlc3 ohlc4].freeze 13 | ].flatten.freeze 14 | 15 | attr_reader :series 16 | 17 | def initialize(series:) 18 | @series = series 19 | @sources = {} 20 | end 21 | 22 | def [](source) 23 | raise invalid_source_error(source:) unless ALL_SOURCES.include?(source) 24 | 25 | @sources[source] ||= IndicatorsSource.new(series:, source:) 26 | end 27 | 28 | def <<(tick) 29 | @sources.each_value { |indicator| indicator << tick } 30 | end 31 | 32 | ALL_SOURCES.each do |source| 33 | define_method(source) do 34 | @sources[source] ||= IndicatorsSource.new(series:, source:) 35 | end 36 | end 37 | 38 | def respond_to_missing?(method, *) 39 | oc2.respond_to?(method) 40 | end 41 | 42 | def method_missing(method_name, *args, &block) 43 | return super unless respond_to_missing?(method_name) 44 | 45 | oc2.send(method_name, *args, &block) 46 | end 47 | 48 | private 49 | 50 | def invalid_source_error(source:) 51 | raise Errors::InvalidIndicatorSource, "Invalid indicator source: #{source.inspect}" 52 | end 53 | end 54 | end 55 | -------------------------------------------------------------------------------- /spec/lib/quant/indicators/pivots/woodie_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe Quant::Indicators::Pivots::Woodie do 4 | let(:filename) { fixture_filename("DEUCES-sample.txt", :series) } 5 | let(:series) { Quant::Series.from_file(filename:, symbol: "DEUCES", interval: "1d") } 6 | let(:source) { :oc2 } 7 | 8 | subject { described_class.new(series:, source:) } 9 | 10 | it { is_expected.to be_a(described_class) } 11 | it { expect(subject.series.size).to eq(4) } 12 | it { expect(subject.ticks).to be_a(Array) } 13 | it { expect(subject.ticks.map(&:oc2)).to eq([3.0, 6.0, 12.0, 24.0]) } 14 | it { expect(subject.values.map(&:input)).to eq([2.5, 3.5, 7.0, 14.0]) } 15 | 16 | # Woodie's calculated bands are erratic as-written. The following tests are marked 17 | # as pending until the correct formula is determined. 18 | context "bands" do 19 | it { expect(subject.values.map{ |v| v.h4.round(3) }).to eq([7.0, 11.0, 22.0, 44.0]) } 20 | it { expect(subject.values.map{ |v| v.h3.round(3) }).to eq([5.0, 7.0, 14.0, 28.0]) } 21 | it { expect(subject.values.map{ |v| v.h2.round(3) }).to eq([4.5, 7.5, 15.0, 30.0]) } 22 | it { expect(subject.values.map{ |v| v.h1.round(3) }).to eq([3.0, 5.0, 10.0, 20.0]) } 23 | it { expect(subject.values.map{ |v| v.midpoint.round(3) }).to eq([2.5, 3.5, 7.0, 14.0]) } 24 | it { expect(subject.values.map{ |v| v.h0.round(3) }).to eq(subject.values.map{ |v| v.midpoint.round(3) }) } 25 | it { expect(subject.values.map{ |v| v.l1.round(3) }).to eq([1.0, 3.0, 6.0, 12.0]) } 26 | it { expect(subject.values.map{ |v| v.l2.round(3) }).to eq([0.5, -0.5, -1.0, -2.0]) } 27 | it { expect(subject.values.map{ |v| v.l3.round(3) }).to eq([-1.0, 1.0, 2.0, 4.0]) } 28 | it { expect(subject.values.map{ |v| v.l4.round(3) }).to eq([-3.0, -3.0, -6.0, -12.0]) } 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /spec/lib/quant/indicators/pivots/guppy_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe Quant::Indicators::Pivots::Guppy do 4 | let(:filename) { fixture_filename("DEUCES-sample.txt", :series) } 5 | let(:series) { Quant::Series.from_file(filename:, symbol: "DEUCES", interval: "1d") } 6 | let(:source) { :oc2 } 7 | 8 | subject { described_class.new(series:, source:) } 9 | 10 | it { is_expected.to be_a(described_class) } 11 | it { expect(subject.series.size).to eq(4) } 12 | it { expect(subject.ticks).to be_a(Array) } 13 | it { expect(subject.values.map(&:input)).to eq([3.0, 6.0, 12.0, 24.0]) } 14 | 15 | context "bands" do 16 | it { expect(subject.values.map{ |v| v.h7.round(3) }).to eq([3.0, 3.231, 3.905, 5.451]) } 17 | it { expect(subject.values.map{ |v| v.h6.round(3) }).to eq([3.0, 3.286, 4.116, 6.009]) } 18 | it { expect(subject.values.map{ |v| v.h1.round(3) }).to eq([3.0, 4.0, 6.667, 12.444]) } 19 | it { expect(subject.values.map{ |v| v.midpoint.round(3) }).to eq([3.0, 4.5, 8.25, 16.125]) } 20 | it { expect(subject.values.map{ |v| v.h0.round(3) }).to eq(subject.values.map{ |v| v.midpoint.round(3) }) } 21 | it { expect(subject.values.map{ |v| v.l1.round(3) }).to eq([3.0, 3.194, 3.762, 5.067]) } 22 | it { expect(subject.values.map{ |v| v.l6.round(3) }).to eq([3.0, 3.098, 3.39, 4.066]) } 23 | end 24 | 25 | context "bands do not intersect each other" do 26 | %i[midpoint h1 h2 h3 h4 h5 h6 h7 l1 l2 l3 l4 l5 l6 l7 l8].each_cons(2) do |above_band, below_band| 27 | it "band #{above_band.inspect} is above band #{below_band.inspect}" do 28 | compare_values = subject.values.drop(1) # first value is often zero, which isn't truthy for "positive?" 29 | expect(compare_values.map{ |v| v.send(above_band) - v.send(below_band) }).to all be_positive 30 | end 31 | end 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /lib/quant/mixins/weighted_moving_average.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Quant 4 | module Mixins 5 | module WeightedMovingAverage 6 | using Quant 7 | # Computes the Weighted Moving Average (WMA) of the series, using the four most recent data points. 8 | # 9 | # @param source [Symbol] the source of the data points to be used in the calculation. 10 | # @return [Float] the weighted average of the series. 11 | # @raise [ArgumentError] if the source is not a Symbol. 12 | # @example 13 | # p0.wma = weighted_average(:close_price) 14 | def weighted_moving_average(source) 15 | raise ArgumentError, "source must be a Symbol" unless source.is_a?(Symbol) 16 | 17 | [4.0 * p0.send(source), 18 | 3.0 * p1.send(source), 19 | 2.0 * p2.send(source), 20 | p3.send(source)].sum / 10.0 21 | end 22 | alias wma weighted_moving_average 23 | 24 | # Computes the Weighted Moving Average (WMA) of the series, using the seven most recent data points. 25 | # 26 | # @param source [Symbol] the source of the data points to be used in the calculation. 27 | # @return [Float] the weighted average of the series. 28 | # @raise [ArgumentError] if the source is not a Symbol. 29 | # @example 30 | # p0.wma = weighted_average(:close_price) 31 | def extended_weighted_moving_average(source) 32 | raise ArgumentError, "source must be a Symbol" unless source.is_a?(Symbol) 33 | 34 | [7.0 * p0.send(source), 35 | 6.0 * p1.send(source), 36 | 5.0 * p2.send(source), 37 | 4.0 * p3.send(source), 38 | 3.0 * p(4).send(source), 39 | 2.0 * p(5).send(source), 40 | p(6).send(source)].sum / 28.0 41 | end 42 | alias ewma extended_weighted_moving_average 43 | end 44 | end 45 | end 46 | -------------------------------------------------------------------------------- /spec/lib/quant/indicators/pivots/bollinger_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe Quant::Indicators::Pivots::Bollinger do 4 | let(:filename) { fixture_filename("DEUCES-sample.txt", :series) } 5 | let(:series) { Quant::Series.from_file(filename:, symbol: "DEUCES", interval: "1d") } 6 | let(:source) { :oc2 } 7 | 8 | subject { described_class.new(series:, source:) } 9 | 10 | it { is_expected.to be_a(described_class) } 11 | it { expect(subject.series.size).to eq(4) } 12 | it { expect(subject.ticks).to be_a(Array) } 13 | it { expect(subject.values.map(&:input)).to eq([3.0, 6.0, 12.0, 24.0]) } 14 | it { expect(subject.values.map{ |v| v.std_dev.round(3) }).to eq([0.0, 1.985, 4.985, 10.365]) } 15 | 16 | context "bands" do 17 | it { expect(subject.values.map{ |v| v.h6.round(3) }).to eq([3.0, 8.162, 16.168, 30.624]) } 18 | it { expect(subject.values.map{ |v| v.h1.round(3) }).to eq([3.0, 5.185, 8.691, 15.077]) } 19 | it { expect(subject.values.map{ |v| v.midpoint.round(3) }).to eq([3.0, 3.2, 3.707, 4.712]) } 20 | it { expect(subject.values.map{ |v| v.h0.round(3) }).to eq(subject.values.map{ |v| v.midpoint.round(3) }) } 21 | it { expect(subject.values.map{ |v| v.l1.round(3) }).to eq([3.0, 1.215, -1.278, -5.652]) } 22 | it { expect(subject.values.map{ |v| v.l6.round(3) }).to eq([3.0, -1.762, -8.755, -21.199]) } 23 | end 24 | 25 | context "bands do not intersect each other" do 26 | %i[h8 h7 h6 h5 h4 h3 h2 h1 midpoint l1 l2 l3 l4 l5 l6 l7 l8].each_cons(2) do |above_band, below_band| 27 | it "band #{above_band.inspect} is above band #{below_band.inspect}" do 28 | compare_values = subject.values.drop(1) # first value is often zero, which isn't truthy for "positive?" 29 | expect(compare_values.map{ |v| v.send(above_band) - v.send(below_band) }).to all be_positive 30 | end 31 | end 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /lib/quant/ticks/serializers/spot.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Quant 4 | module Ticks 5 | module Serializers 6 | class Spot < Tick 7 | # Returns a +Quant::Ticks::Tick+ from a valid JSON +String+. 8 | # @param json [String] 9 | # @param tick_class [Quant::Ticks::Tick] 10 | # @return [Quant::Ticks::Tick] 11 | # @example 12 | # json = "{\"ct\":\"2024-01-15 03:12:23 UTC\", \"cp\":5.0, \"iv\":\"1d\", \"bv\":0.0, \"tv\":0.0, \"t\":0}" 13 | # Quant::Ticks::Serializers::Tick.from_json(json, tick_class: Quant::Ticks::Spot) 14 | def self.from_json(json, tick_class:) 15 | hash = Oj.load(json) 16 | from(hash, tick_class:) 17 | end 18 | 19 | # Returns a +Hash+ of the Spot tick's key properties 20 | # 21 | # Serialized Keys: 22 | # 23 | # - ct: close timestamp 24 | # - cp: close price 25 | # - bv: base volume 26 | # - tv: target volume 27 | # - t: trades 28 | # 29 | # @param tick [Quant::Ticks::Tick] 30 | # @return [Hash] 31 | # @example 32 | # Quant::Ticks::Serializers::Tick.to_h(tick) 33 | # # => { "ct" => "2024-02-13 03:12:23 UTC", "cp" => 5.0, "bv" => 0.0, "tv" => 0.0, "t" => 0 } 34 | def self.to_h(tick) 35 | { "ct" => tick.close_timestamp, 36 | "cp" => tick.close_price, 37 | "bv" => tick.base_volume, 38 | "tv" => tick.target_volume, 39 | "t" => tick.trades } 40 | end 41 | 42 | def self.from(hash, tick_class:) 43 | tick_class.new( 44 | close_timestamp: hash["ct"], 45 | close_price: hash["cp"], 46 | base_volume: hash["bv"], 47 | target_volume: hash["tv"], 48 | trades: hash["t"] 49 | ) 50 | end 51 | end 52 | end 53 | end 54 | end 55 | -------------------------------------------------------------------------------- /lib/quant/indicators_registry.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Quant 4 | module IndicatorsRegistry 5 | def self.included(base) 6 | base.extend(ClassMethods) 7 | end 8 | 9 | def define_indicator_accessors(indicator_source:) 10 | self.class.define_indicator_accessors(indicator_source:) 11 | end 12 | 13 | module ClassMethods 14 | def registry 15 | @registry ||= {} 16 | end 17 | 18 | class RegistryEntry 19 | attr_reader :name, :indicator_class 20 | 21 | def initialize(name:, indicator_class:) 22 | @name = name 23 | @indicator_class = indicator_class 24 | end 25 | 26 | def key 27 | "#{indicator_class.name}::#{name}" 28 | end 29 | 30 | def standard? 31 | !pivot? && !dominant_cycle? 32 | end 33 | 34 | def pivot? 35 | indicator_class < Indicators::Pivots::Pivot 36 | end 37 | 38 | def dominant_cycle? 39 | indicator_class < Indicators::DominantCycles::DominantCycle 40 | end 41 | end 42 | 43 | def register(name:, indicator_class:) 44 | entry = RegistryEntry.new(name:, indicator_class:) 45 | registry[entry.key] = entry 46 | # registry[name] = indicator_class 47 | end 48 | 49 | def registry_entries_for(indicator_source:) 50 | return registry.values.select(&:pivot?) if indicator_source.is_a?(PivotsSource) 51 | return registry.values.select(&:dominant_cycle?) if indicator_source.is_a?(DominantCyclesSource) 52 | 53 | registry.values.select(&:standard?) 54 | end 55 | 56 | def define_indicator_accessors(indicator_source:) 57 | registry_entries_for(indicator_source:).each do |entry| 58 | indicator_source.define_singleton_method(entry.name) { indicator(entry.indicator_class) } 59 | end 60 | end 61 | end 62 | end 63 | end -------------------------------------------------------------------------------- /lib/quant/indicators/snr.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Quant 4 | module Indicators 5 | class SnrPoint < IndicatorPoint 6 | attribute :smooth, default: 0.0 7 | attribute :detrend, default: 0.0 8 | attribute :i1, default: 0.0 9 | attribute :q1, default: 0.0 10 | attribute :noise, default: 0.0 11 | attribute :signal, default: 0.0 12 | attribute :ratio, default: 0.0 13 | attribute :state, default: 0 14 | end 15 | 16 | class Snr < Indicator 17 | register name: :snr 18 | depends_on DominantCycles::Homodyne 19 | 20 | def homodyne_dominant_cycle 21 | series.indicators[source].dominant_cycles.homodyne 22 | end 23 | 24 | def current_dominant_cycle 25 | homodyne_dominant_cycle.points[t0] 26 | end 27 | 28 | def threshold 29 | @threshold ||= 10 * Math.log(0.5)**2 30 | end 31 | 32 | def compute_values 33 | current_dominant_cycle.tap do |dc| 34 | p0.i1 = dc.i1 35 | p0.q1 = dc.q1 36 | end 37 | end 38 | 39 | def compute_noise 40 | noise = (p0.input - p2.input).abs 41 | p0.noise = p1.noise.zero? ? noise : (0.1 * noise) + (0.9 * p1.noise) 42 | end 43 | 44 | def compute_ratio 45 | # p0.ratio = 0.25 * (10 * Math.log(p0.i1**2 + p0.q1**2) / Math.log(10)) + 0.75 * p1.ratio 46 | # ratio = .25*(10 * Log(I1*I1 + Q1*Q1)/(Range*Range))/Log(10) + 6) + .75*ratio[1] 47 | if p0 == p1 48 | p0.signal = 0.0 49 | p0.ratio = 1.0 50 | else 51 | p0.signal = threshold + 10.0 * (Math.log((p0.i1**2 + p0.q1**2)/(p0.noise**2)) / Math.log(10)) 52 | p0.ratio = (0.25 * p0.signal) + (0.75 * p1.ratio) 53 | end 54 | p0.state = p0.ratio >= threshold ? 1 : 0 55 | end 56 | 57 | def compute 58 | compute_values 59 | compute_noise 60 | compute_ratio 61 | end 62 | end 63 | end 64 | end 65 | -------------------------------------------------------------------------------- /lib/quant/mixins/fisher_transform.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Quant 4 | module Mixins 5 | # Fisher Transforms 6 | # • Price is not a Gaussian (Bell Curve) distribution, even though many 7 | # technical analysis formulas falsely assume that it is. Bell Curve tails 8 | # are missing. 9 | # – If $10 stock were Gaussian, it could go up or down $20 10 | # – Standard deviation based indicators like Bollinger Bands 11 | # and zScore make the Gaussian assumption error 12 | # 13 | # • TheFisher Transform converts almost any probability distribution 14 | # in a Gaussian-like one 15 | # – Expands the distribution and creates tails 16 | # 17 | # • The Inverse Fisher Transform converts almost any probability 18 | # distribution into a square wave 19 | # – Compresses, removes low amplitude variations 20 | module FisherTransform 21 | # inverse fisher transform 22 | # https://www.mql5.com/en/articles/303 23 | def inverse_fisher_transform(value, scale_factor: 1.0) 24 | r = (Math.exp(2.0 * scale_factor * value) - 1.0) / (Math.exp(2.0 * scale_factor * value) + 1.0) 25 | r.nan? ? 0.0 : r 26 | end 27 | alias ift inverse_fisher_transform 28 | 29 | def relative_fisher_transform(value, max_value:) 30 | max_value.zero? ? 0.0 : fisher_transform(value / max_value) 31 | end 32 | alias rft relative_fisher_transform 33 | 34 | # The absolute value passed must be < 1.0 35 | def fisher_transform(value) 36 | raise ArgumentError, "value (#{value}) must be between -1.0 and 1.0" unless value.abs <= 1.0 37 | 38 | result = 0.5 * Math.log((1.0 + value) / (1.0 - value)) 39 | result.nan? ? 0.0 : result 40 | rescue Math::DomainError => e 41 | raise Math::DomainError, "#{e.message}: cannot take the Log of #{value}: #{(1 + value) / (1 - value)}" 42 | end 43 | alias fisher fisher_transform 44 | alias ft fisher_transform 45 | end 46 | end 47 | end 48 | -------------------------------------------------------------------------------- /spec/lib/quant/indicators/pivots/pivot_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe Quant::Indicators::Pivots::Pivot do 4 | let(:filename) { fixture_filename("DEUCES-sample.txt", :series) } 5 | let(:series) { Quant::Series.from_file(filename:, symbol: "DEUCES", interval: "1d") } 6 | let(:source) { :oc2 } 7 | 8 | subject { described_class.new(series:, source:) } 9 | 10 | context "extents" do 11 | it { expect(subject.values.map{ |v| v.highest.round(3) }).to eq([3.0, 6.0, 12.0, 24.0]) } 12 | it { expect(subject.values.map{ |v| v.avg_high.round(3) }).to eq([4.0, 4.529, 6.532, 11.585]) } 13 | it { expect(subject.values.map{ |v| v.lowest.round(3) }).to eq([3.0, 3.0, 3.0, 3.0]) } 14 | it { expect(subject.values.map{ |v| v.low_price.round(3) }).to eq([2.0, 4.0, 8.0, 16.0]) } 15 | it { expect(subject.values.map{ |v| v.high_price.round(3) }).to eq([4.0, 8.0, 16.0, 32.0]) } 16 | it { expect(subject.values.map{ |v| v.range.round(3) }).to eq([2.0, 4.0, 8.0, 16.0]) } 17 | end 18 | 19 | it { is_expected.to be_a(described_class) } 20 | it { expect(subject.series.size).to eq(4) } 21 | it { expect(subject.ticks).to be_a(Array) } 22 | it { expect(subject.values.map(&:input)).to eq([3.0, 6.0, 12.0, 24.0]) } 23 | it { expect(subject.values.map(&:midpoint)).to eq([3.0, 6.0, 12.0, 24.0]) } 24 | 25 | it { expect(subject.values.map(&:highest)).to eq([3.0, 6.0, 12.0, 24.0]) } 26 | it { expect(subject.values.map(&:lowest)).to eq([3.0, 3.0, 3.0, 3.0]) } 27 | 28 | it { expect(subject.values.map(&:high_price)).to eq([4.0, 8.0, 16.0, 32.0]) } 29 | it { expect(subject.values.map(&:low_price)).to eq([2.0, 4.0, 8.0, 16.0]) } 30 | it { expect(subject.values.map(&:range)).to eq([2.0, 4.0, 8.0, 16.0]) } 31 | 32 | it { expect(subject.values.map{ |v| v.avg_high.round(3) }).to eq([4.0, 4.529, 6.532, 11.585]) } 33 | it { expect(subject.values.map{ |v| v.avg_low.round(3) }).to eq([2.0, 2.265, 3.266, 5.793]) } 34 | it { expect(subject.values.map{ |v| v.avg_range.round(3) }).to eq([0.265, 0.759, 2.17, 5.151]) } 35 | end 36 | -------------------------------------------------------------------------------- /spec/lib/quant/indicators/adx_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe Quant::Indicators::Adx do 4 | let(:apple_fixture_filename) { fixture_filename("AAPL-19990104_19990107.txt", :series) } 5 | let(:series) { Quant::Series.from_file(filename: apple_fixture_filename, symbol: "AAPL", interval: :daily) } 6 | let(:source) { :oc2 } 7 | 8 | subject { described_class.new(series:, source:) } 9 | 10 | it { is_expected.to be_a(described_class) } 11 | 12 | context "sine series" do 13 | let(:period) { 40 } 14 | let(:cycles) { 4 } 15 | let(:uniq_data_points) { cycles * period / cycles } # sine is cyclical, so we expect a few unique data points 16 | let(:series) do 17 | # period bar sine wave 18 | Quant::Series.new(symbol: "SINE", interval: "1d").tap do |series| 19 | cycles.times do 20 | (0...period).each do |degree| 21 | radians = degree * 2 * Math::PI / period 22 | series << 5.0 * Math.sin(radians) + 10.0 23 | end 24 | end 25 | end 26 | end 27 | 28 | it { expect(subject.series.size).to eq(160) } 29 | 30 | it { expect(subject.values.last(5).map{ |v| v.dmu.round(4) }).to eq([0.5096, 0.5966, 0.669, 0.7249, 0.7629]) } 31 | it { expect(subject.values.last(5).map{ |v| v.dmd.round(4) }).to eq([0.5096, 0.5966, 0.669, 0.7249, 0.7629]) } 32 | 33 | it { expect(subject.values.last(5).map{ |v| v.diu.round(4) }).to eq([1.6077, 15.8702, 29.1715, 38.7976, 45.3035]) } 34 | it { expect(subject.values.last(5).map{ |v| v.did.round(4) }).to eq([1.6077, 15.8702, 29.1715, 38.7976, 45.3035]) } 35 | 36 | it { expect(subject.values.last(5).map{ |v| v.di.round(4) }).to eq([3.186, 0.4493, 0.228, 0.1241, 0.0718]) } 37 | 38 | it "is roughly half and half" do 39 | direction_of_changes = subject.values.each_cons(2).map{ |(a, b)| a.value - b.value > 0 } 40 | value_counts = direction_of_changes.group_by(&:itself).transform_values(&:count) 41 | 42 | expect(value_counts[true].to_f / value_counts[false]).to be > 0.8 43 | end 44 | end 45 | end 46 | -------------------------------------------------------------------------------- /spec/lib/quant/indicators/pivots/camarilla_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe Quant::Indicators::Pivots::Camarilla do 4 | let(:filename) { fixture_filename("DEUCES-sample.txt", :series) } 5 | let(:series) { Quant::Series.from_file(filename:, symbol: "DEUCES", interval: "1d") } 6 | let(:source) { :oc2 } 7 | 8 | subject { described_class.new(series:, source:) } 9 | 10 | it { is_expected.to be_a(described_class) } 11 | it { expect(subject.series.size).to eq(4) } 12 | it { expect(subject.ticks).to be_a(Array) } 13 | it { expect(subject.values.map(&:input)).to eq([3.0, 6.0, 12.0, 24.0]) } 14 | 15 | context "bands" do 16 | it { expect(subject.values.map{ |v| v.range.round(3) }).to eq([2.0, 4.0, 8.0, 16.0]) } 17 | it { expect(subject.values.map{ |v| v.h6.round(3) }).to eq([8.0, 16.0, 32.0, 64.0]) } 18 | it { expect(subject.values.map{ |v| v.h5.round(3) }).to eq([7.84, 15.68, 31.36, 62.72]) } 19 | it { expect(subject.values.map{ |v| v.h4.round(3) }).to eq([7.0, 14.0, 28.0, 56.0]) } 20 | it { expect(subject.values.map{ |v| v.h3.round(3) }).to eq([6.5, 13.0, 26.0, 52.0]) } 21 | it { expect(subject.values.map{ |v| v.h2.round(3) }).to eq([6.334, 12.668, 25.336, 50.672]) } 22 | it { expect(subject.values.map{ |v| v.h1.round(3) }).to eq([6.166, 12.332, 24.664, 49.328]) } 23 | it { expect(subject.values.map{ |v| v.midpoint.round(3) }).to eq([3.333, 6.667, 13.333, 26.667]) } 24 | it { expect(subject.values.map{ |v| v.h0.round(3) }).to eq(subject.values.map{ |v| v.midpoint.round(3) }) } 25 | it { expect(subject.values.map{ |v| v.l1.round(3) }).to eq([1.834, 3.668, 7.336, 14.672]) } 26 | it { expect(subject.values.map{ |v| v.l2.round(3) }).to eq([1.666, 3.332, 6.664, 13.328]) } 27 | it { expect(subject.values.map{ |v| v.l3.round(3) }).to eq([1.5, 3.0, 6.0, 12.0]) } 28 | it { expect(subject.values.map{ |v| v.l4.round(3) }).to eq([1.0, 2.0, 4.0, 8.0]) } 29 | it { expect(subject.values.map{ |v| v.l5.round(3) }).to eq([0.16, 0.32, 0.64, 1.28]) } 30 | it { expect(subject.values.map{ |v| v.l6.round(3) }).to eq([0.0, 0.0, 0.0, 0.0]) } 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /spec/lib/quant/ticks/serializers/spot_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe Quant::Ticks::Serializers::Spot do 4 | let(:current_time) { Quant.current_time.round } 5 | let(:one_second) { 1 } 6 | let(:tick_class) { Quant::Ticks::Spot } 7 | 8 | describe ".from" do 9 | let(:hash) { { "ct" => current_time.to_i, "cp" => 1.0, "bv" => 2.0, "tv" => 3.0 } } 10 | 11 | subject(:tick) { described_class.from(hash, tick_class:) } 12 | 13 | context "valid" do 14 | it { is_expected.to be_a(tick_class) } 15 | 16 | it "has the correct attributes" do 17 | expect(tick.close_timestamp).to eq(current_time) 18 | expect(tick.close_price).to eq(1.0) 19 | expect(tick.base_volume).to eq(2.0) 20 | expect(tick.target_volume).to eq(3.0) 21 | end 22 | 23 | describe "#to_h" do 24 | subject { tick.to_h } 25 | 26 | it { expect(subject["ct"]).to eq(current_time) } 27 | it { expect(subject["cp"]).to eq(1.0) } 28 | it { expect(subject["bv"]).to eq(2.0) } 29 | it { expect(subject["tv"]).to eq(3.0) } 30 | end 31 | end 32 | 33 | context "without volume" do 34 | let(:hash) { { "ct" => current_time.to_i, "cp" => 1.0 } } 35 | 36 | it "has the correct attributes" do 37 | expect(tick.close_timestamp).to eq(current_time) 38 | expect(tick.close_price).to eq(1.0) 39 | expect(tick.base_volume).to eq(0.0) 40 | expect(tick.target_volume).to eq(0.0) 41 | end 42 | end 43 | end 44 | 45 | describe ".from_json" do 46 | let(:json) { Oj.dump({ "ct" => current_time, "cp" => 1.0, "bv" => 2.0, "tv" => 3.0 }) } 47 | 48 | subject(:tick) { described_class.from_json(json, tick_class:) } 49 | 50 | context "valid" do 51 | it { is_expected.to be_a(tick_class) } 52 | 53 | it "has the correct attributes" do 54 | expect(tick.close_timestamp).to eq(current_time) 55 | expect(tick.close_price).to eq(1.0) 56 | expect(tick.base_volume).to eq(2.0) 57 | expect(tick.target_volume).to eq(3.0) 58 | end 59 | end 60 | end 61 | end 62 | -------------------------------------------------------------------------------- /lib/quant/mixins/super_smoother.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Quant 4 | module Mixins 5 | # Super Smoother Filters provide a way to smooth out the noise in a series 6 | # without out introducing undesirable lag that you would other get with 7 | # traditional moving averages. 8 | # 9 | # The EMA only reduces the amplitude at the Nyquist frequency by 13 dB. 10 | # On the other hand, the SuperSmoother filter theoretically completely 11 | # eliminates components at the Nyquist Frequency. The added benefit is 12 | # that the SuperSmoother filter has significantly less lag than the EMA. 13 | module SuperSmoother 14 | # https://www.mesasoftware.com/papers/PredictiveIndicators.pdf 15 | def two_pole_super_smooth(source, period:, previous:) 16 | raise ArgumentError, "source must be a Symbol" unless source.is_a?(Symbol) 17 | 18 | radians = Math.sqrt(2) * Math::PI / period 19 | a1 = Math.exp(-radians) 20 | 21 | c3 = -a1**2 22 | c2 = 2.0 * a1 * Math.cos(radians) 23 | c1 = 1.0 - c2 - c3 24 | 25 | v1 = (p0.send(source) + p1.send(source)) * 0.5 26 | v2 = p2.send(previous) 27 | v3 = p3.send(previous) 28 | 29 | (c1 * v1) + (c2 * v2) + (c3 * v3) 30 | end 31 | 32 | alias super_smoother two_pole_super_smooth 33 | alias ss2p two_pole_super_smooth 34 | 35 | def three_pole_super_smooth(source, period:, previous:) 36 | raise ArgumentError, "source must be a Symbol" unless source.is_a?(Symbol) 37 | 38 | radians = Math::PI / period 39 | a1 = Math.exp(-radians) 40 | b1 = 2 * a1 * Math.cos(Math.sqrt(3) * radians) 41 | c1 = a1**2 42 | 43 | c4 = c1**2 44 | c3 = -(c1 + b1 * c1) 45 | c2 = b1 + c1 46 | c1 = 1 - c2 - c3 - c4 47 | 48 | v0 = p0.send(source) 49 | v1 = p1.send(previous) 50 | v2 = p2.send(previous) 51 | v3 = p3.send(previous) 52 | 53 | (c1 * v0) + (c2 * v1) + (c3 * v2) + (c4 * v3) 54 | end 55 | alias ss3p three_pole_super_smooth 56 | end 57 | end 58 | end 59 | -------------------------------------------------------------------------------- /spec/lib/quant/indicators/pivots/traditional_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe Quant::Indicators::Pivots::Traditional do 4 | let(:filename) { fixture_filename("DEUCES-sample.txt", :series) } 5 | let(:series) { Quant::Series.from_file(filename:, symbol: "DEUCES", interval: "1d") } 6 | let(:source) { :oc2 } 7 | 8 | subject { described_class.new(series:, source:) } 9 | 10 | it { is_expected.to be_a(described_class) } 11 | it { expect(subject.series.size).to eq(4) } 12 | it { expect(subject.ticks).to be_a(Array) } 13 | it { expect(subject.ticks.map(&:oc2)).to eq([3.0, 6.0, 12.0, 24.0]) } 14 | it { expect(subject.values.map(&:input)).to eq([3.0, 6.0, 12.0, 24.0]) } 15 | 16 | # Woodie's calculated bands are erratic as-written. The following tests are marked 17 | # as pending until the correct formula is determined. 18 | context "bands" do 19 | it { expect(subject.values.map{ |v| v.h3.round(3) }).to eq([6.0, 12.0, 24.0, 48.0]) } 20 | it { expect(subject.values.map{ |v| v.h2.round(3) }).to eq([5.0, 10.0, 20.0, 40.0]) } 21 | it { expect(subject.values.map{ |v| v.h1.round(3) }).to eq([4.0, 8.0, 16.0, 32.0]) } 22 | it { expect(subject.values.map{ |v| v.midpoint.round(3) }).to eq([3.0, 6.0, 12.0, 24.0]) } 23 | it { expect(subject.values.map{ |v| v.h0.round(3) }).to eq(subject.values.map{ |v| v.midpoint.round(3) }) } 24 | it { expect(subject.values.map{ |v| v.l1.round(3) }).to eq([2.0, 4.0, 8.0, 16.0]) } 25 | it { expect(subject.values.map{ |v| v.l2.round(3) }).to eq([1.0, 2.0, 4.0, 8.0]) } 26 | it { expect(subject.values.map{ |v| v.l3.round(3) }).to eq([0.0, 0.0, 0.0, 0.0]) } 27 | end 28 | 29 | context "bands do not intersect each other" do 30 | %i[h3 h2 h1 midpoint l1 l2 l3].each_cons(2) do |above_band, below_band| 31 | it "band #{above_band.inspect} is above band #{below_band.inspect}" do 32 | compare_values = subject.values.drop(1) # first value is often zero, which isn't truthy for "positive?" 33 | expect(compare_values.map{ |v| v.send(above_band) - v.send(below_band) }).to all be_positive 34 | end 35 | end 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /lib/quant/time_period.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Quant 4 | class TimePeriod 5 | LOWER_BOUND = TimeMethods::EPOCH_TIME 6 | 7 | def initialize(start_at: nil, end_at: nil, span: nil) 8 | @start_at = as_start_time(start_at) 9 | @end_at = as_end_time(end_at) 10 | validate_bounds! 11 | 12 | @start_at = @end_at - span if !lower_bound? && span 13 | @end_at = @start_at + span if !upper_bound? && span 14 | end 15 | 16 | def as_start_time(value) 17 | return value if value.nil? || value.is_a?(Time) 18 | 19 | value.is_a?(Date) ? beginning_of_day(value) : value.to_time 20 | end 21 | 22 | def as_end_time(value) 23 | return value if value.nil? || value.is_a?(Time) 24 | 25 | value.is_a?(Date) ? end_of_day(value) : value.to_time 26 | end 27 | 28 | def end_of_day(date) 29 | Time.utc(date.year, date.month, date.day, 23, 59, 59) 30 | end 31 | 32 | def beginning_of_day(date) 33 | Time.utc(date.year, date.month, date.day) 34 | end 35 | 36 | def validate_bounds! 37 | return if lower_bound? || upper_bound? 38 | 39 | raise "TimePeriod cannot be unbound at start_at and end_at" 40 | end 41 | 42 | def cover?(value) 43 | (start_at..end_at).cover?(value) 44 | end 45 | 46 | def start_at 47 | (@start_at || LOWER_BOUND).round 48 | end 49 | 50 | def lower_bound? 51 | !lower_unbound? 52 | end 53 | 54 | def lower_unbound? 55 | @start_at.nil? 56 | end 57 | 58 | def upper_unbound? 59 | @end_at.nil? 60 | end 61 | 62 | def upper_bound? 63 | !upper_unbound? 64 | end 65 | 66 | def end_at 67 | (@end_at || Time.now.utc).round 68 | end 69 | 70 | def duration 71 | end_at - start_at 72 | end 73 | 74 | def ==(other) 75 | return false unless other.is_a?(TimePeriod) 76 | 77 | [lower_bound?, upper_bound?, start_at, end_at] == 78 | [other.lower_bound?, other.upper_bound?, other.start_at, other.end_at] 79 | end 80 | 81 | def to_h 82 | { start_at:, end_at: } 83 | end 84 | end 85 | end 86 | -------------------------------------------------------------------------------- /spec/lib/quant/asset_class_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe Quant::AssetClass do 4 | subject { described_class.new(name) } 5 | 6 | context "when initialized with a standard name symbol" do 7 | let(:name) { :stock } 8 | let(:json) { Oj.dump({ "sc" => :stock }) } 9 | 10 | it { is_expected.to be_stock } 11 | it { expect(subject.asset_class).to eq :stock } 12 | it { expect(subject.to_h).to eq({ "sc" => :stock }) } 13 | it { expect(subject.to_json).to eq(json) } 14 | it { expect(subject.to_s).to eq("stock") } 15 | 16 | context "comparable" do 17 | it { expect(subject == "stock").to be true } 18 | it { expect(subject == :stock).to be true } 19 | it { expect(subject == "us_equity").to be true } 20 | it { expect(subject == described_class.new("stock")).to be true } 21 | it { expect(subject == 99).to be false } 22 | end 23 | end 24 | 25 | context "when initialized with a standard name String" do 26 | let(:name) { "ETF" } 27 | let(:json) { Oj.dump({ "sc" => :etf }) } 28 | 29 | it { is_expected.to be_etf } 30 | it { expect(subject.asset_class).to eq :etf } 31 | it { expect(subject.to_h).to eq({ "sc" => :etf }) } 32 | it { expect(subject.to_json).to eq(json) } 33 | end 34 | 35 | context "when initialized with an alternate name" do 36 | let(:name) { "us_equity" } 37 | let(:json) { Oj.dump({ "sc" => :stock }) } 38 | 39 | it { is_expected.to be_stock } 40 | it { expect(subject.asset_class).to eq :stock } 41 | it { expect(subject.to_h).to eq({ "sc" => :stock }) } 42 | it { expect(subject.to_json).to eq(json) } 43 | end 44 | 45 | context "when initialized with nil" do 46 | let(:name) { nil } 47 | 48 | it "raises an error" do 49 | expect { subject }.to raise_error Quant::Errors::AssetClassError, "Unknown asset class: nil" 50 | end 51 | end 52 | 53 | context "when initialized with bogus" do 54 | let(:name) { "bogus" } 55 | 56 | it "raises an error" do 57 | expect { subject }.to raise_error Quant::Errors::AssetClassError, 'Unknown asset class: "bogus"' 58 | end 59 | end 60 | end 61 | -------------------------------------------------------------------------------- /spec/lib/quant/mixins/simple_moving_average_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module SmaMixinTest 4 | class TestPoint < Quant::Indicators::IndicatorPoint 5 | attribute :sma, default: 0.0 6 | end 7 | 8 | class TestIndicator < Quant::Indicators::Indicator 9 | include Quant::Mixins::MovingAverages 10 | 11 | def points_class 12 | TestPoint 13 | end 14 | 15 | def compute 16 | p0.sma = simple_moving_average(:input, period: 3) 17 | end 18 | end 19 | 20 | RSpec.describe Quant::Mixins::SimpleMovingAverage do 21 | let(:filename) { fixture_filename("DEUCES-sample.txt", :series) } 22 | let(:series) { Quant::Series.from_file(filename:, symbol: "DEUCES", interval: "1d") } 23 | 24 | subject { TestIndicator.new(series:, source: :oc2) } 25 | 26 | before { series.indicators.oc2.attach(indicator_class: TestIndicator, name: :sma) } 27 | 28 | context "deuces sample prices" do 29 | it { is_expected.to be_a(TestIndicator) } 30 | it { expect(subject.ticks.size).to eq(subject.series.size) } 31 | it { expect(subject.values.map(&:input)).to eq([3.0, 6.0, 12.0, 24.0]) } 32 | it { expect(subject.values.map(&:sma)).to eq([3.0, 4.5, 7.0, 14.0]) } 33 | end 34 | 35 | context "growing price" do 36 | let(:series) { Quant::Series.new(symbol: "SMA", interval: "1d") } 37 | 38 | [[1, 1.0], 39 | [2, 1.5], 40 | [3, 2.0], 41 | [4, 3.0], 42 | [5, 4.0], 43 | [6, 5.0]].each do |n, expected| 44 | dataset = (1..n).to_a 45 | 46 | it "is #{expected.inspect} when series: is #{dataset.inspect}" do 47 | dataset.each { |price| series << price } 48 | expect(subject.p0.sma).to eq expected 49 | end 50 | end 51 | end 52 | 53 | context "static price" do 54 | let(:series) { Quant::Series.new(symbol: "SMA", interval: "1d") } 55 | 56 | before { 25.times { series << 5.0 } } 57 | 58 | it { expect(subject.ticks.size).to eq(subject.series.size) } 59 | it { expect(subject.values.map(&:input)).to be_all(5.0) } 60 | it { expect(subject.values.map(&:sma)).to be_all(5.0) } 61 | end 62 | end 63 | end 64 | -------------------------------------------------------------------------------- /lib/quant/indicators/pivots/woodie.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Quant 4 | module Indicators 5 | module Pivots 6 | # One of the key differences in calculating Woodie's Pivot Point to other pivot 7 | # points is that the current session's open price is used in the PP formula with 8 | # the previous session's high and low. At the time-of-day that we calculate the 9 | # pivot points on this site in our Daily Notes we do not have the opening price 10 | # so we use the Classic formula for the Pivot Point and vary the R3 and R4 11 | # formula as per Woodie's formulas. 12 | 13 | # Formulas: 14 | # R4 = R3 + RANGE 15 | # R3 = H + 2 * (PP - L) (same as: R1 + RANGE) 16 | # R2 = PP + RANGE 17 | # R1 = (2 * PP) - LOW 18 | 19 | # PP = (HIGH + LOW + (TODAY'S OPEN * 2)) / 4 20 | # S1 = (2 * PP) - HIGH 21 | # S2 = PP - RANGE 22 | # S3 = L - 2 * (H - PP) (same as: S1 - RANGE) 23 | # S4 = S3 - RANGE 24 | class Woodie < Pivot 25 | register name: :woodie 26 | 27 | def compute_value 28 | p0.input = (t1.high_price + t1.low_price + 2.0 * t0.open_price) / 4.0 29 | end 30 | 31 | def compute_bands 32 | Quant.experimental("Woodie appears erratic, is unproven and may be incorrect.") 33 | 34 | # R1 = (2 * PP) - LOW 35 | p0.h1 = 2.0 * p0.midpoint - t1.low_price 36 | 37 | # R2 = PP + RANGE 38 | p0.h2 = p0.midpoint + p0.range 39 | 40 | # R3 = H + 2 * (PP - L) (same as: R1 + RANGE) 41 | p0.h3 = t1.high_price + 2.0 * (p0.midpoint - t1.low_price) 42 | 43 | # R4 = R3 + RANGE 44 | p0.h4 = p0.h3 + p0.range 45 | 46 | # S1 = (2 * PP) - HIGH 47 | p0.l1 = 2.0 * p0.midpoint - t1.high_price 48 | 49 | # S2 = PP - RANGE 50 | p0.l2 = p0.midpoint - p0.range 51 | 52 | # S3 = L - 2 * (H - PP) (same as: S1 - RANGE) 53 | p0.l3 = t1.low_price - 2.0 * (t1.high_price - p0.midpoint) 54 | 55 | # S4 = S3 - RANGE 56 | p0.l4 = p0.l3 - p0.range 57 | end 58 | end 59 | end 60 | end 61 | end -------------------------------------------------------------------------------- /spec/lib/quant/mixins/exponential_moving_average_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module EmaMixinTest 4 | class TestPoint < Quant::Indicators::IndicatorPoint 5 | attribute :ema, default: :oc2 6 | end 7 | 8 | class TestIndicator < Quant::Indicators::Indicator 9 | include Quant::Mixins::MovingAverages 10 | 11 | def points_class 12 | TestPoint 13 | end 14 | 15 | def compute 16 | p0.ema = exponential_moving_average(:input, period: 3) 17 | end 18 | end 19 | 20 | RSpec.describe Quant::Mixins::ExponentialMovingAverage do 21 | let(:filename) { fixture_filename("DEUCES-sample.txt", :series) } 22 | let(:series) { Quant::Series.from_file(filename:, symbol: "DEUCES", interval: "1d") } 23 | 24 | subject { TestIndicator.new(series:, source: :oc2) } 25 | 26 | before { series.indicators.oc2.attach(indicator_class: TestIndicator, name: :ema) } 27 | 28 | context "deuces sample prices" do 29 | it { is_expected.to be_a(TestIndicator) } 30 | it { expect(subject.ticks.size).to eq(subject.series.size) } 31 | it { expect(subject.values.map(&:input)).to eq([3.0, 6.0, 12.0, 24.0]) } 32 | it { expect(subject.values.map(&:ema)).to eq([3.0, 4.5, 8.25, 16.125]) } 33 | end 34 | 35 | context "growing price" do 36 | let(:series) { Quant::Series.new(symbol: "EMA", interval: "1d") } 37 | 38 | [[1, 1.0], 39 | [2, 1.5], 40 | [3, 2.25], 41 | [4, 3.125], 42 | [5, 4.0625], 43 | [6, 5.03125]].each do |n, expected| 44 | dataset = (1..n).to_a 45 | 46 | it "is #{expected.inspect} when series: is #{dataset.inspect}" do 47 | dataset.each { |price| series << price } 48 | expect(subject.p0.ema).to eq expected 49 | end 50 | end 51 | end 52 | 53 | context "static price" do 54 | let(:series) { Quant::Series.new(symbol: "EMA", interval: "1d") } 55 | 56 | before { 25.times { series << 5.0 } } 57 | 58 | it { expect(subject.ticks.size).to eq(subject.series.size) } 59 | it { expect(subject.values.map(&:input)).to be_all(5.0) } 60 | it { expect(subject.values.map(&:ema)).to be_all(5.0) } 61 | end 62 | end 63 | end 64 | -------------------------------------------------------------------------------- /spec/lib/quant/mixins/hilbert_transform_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module HilbertMixinTest 4 | class TestPoint < Quant::Indicators::IndicatorPoint 5 | attribute :ht, default: 0.0 6 | end 7 | 8 | class TestIndicator < Quant::Indicators::Indicator 9 | include Quant::Mixins::HilbertTransform 10 | 11 | def points_class 12 | TestPoint 13 | end 14 | 15 | def compute 16 | p0.ht = hilbert_transform(:input, period: 3).round(3) 17 | end 18 | end 19 | 20 | RSpec.describe Quant::Mixins::HilbertTransform do 21 | let(:filename) { fixture_filename("DEUCES-sample.txt", :series) } 22 | let(:series) { Quant::Series.from_file(filename:, symbol: "DEUCES", interval: "1d") } 23 | 24 | subject { TestIndicator.new(series:, source: :oc2) } 25 | 26 | before { series.indicators.oc2.attach(indicator_class: TestIndicator, name: :hilbert) } 27 | 28 | context "deuces sample prices" do 29 | it { is_expected.to be_a(TestIndicator) } 30 | it { expect(subject.ticks.size).to eq(subject.series.size) } 31 | it { expect(subject.values.map(&:input)).to eq([3.0, 6.0, 12.0, 24.0]) } 32 | it { expect(subject.values.map(&:ht)).to eq([0.0, 0.221, 0.662, 2.869]) } 33 | end 34 | 35 | context "growing price" do 36 | let(:series) { Quant::Series.new(symbol: "HT", interval: "1d") } 37 | 38 | [[1, 0.0], 39 | [2, 0.074], 40 | [3, 0.147], 41 | [4, 0.662], 42 | [5, 1.177], 43 | [6, 1.251]].each do |n, expected| 44 | dataset = (1..n).to_a 45 | 46 | it "is #{expected.inspect} when series: is #{dataset.inspect}" do 47 | dataset.each { |price| series << price } 48 | expect(subject.p0.ht).to eq expected 49 | end 50 | end 51 | end 52 | 53 | context "static price" do 54 | using Quant 55 | 56 | let(:series) { Quant::Series.new(symbol: "HT", interval: "1d") } 57 | 58 | before { 25.times { series << 5.0 } } 59 | 60 | it { expect(subject.ticks.size).to eq(subject.series.size) } 61 | it { expect(subject.values.map(&:input)).to be_all(5.0) } 62 | it { expect(subject.values.map(&:ht).uniq).to eq([0.0]) } 63 | end 64 | end 65 | end 66 | -------------------------------------------------------------------------------- /lib/quant/indicators/cci.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Quant 4 | module Indicators 5 | class CciPoint < IndicatorPoint 6 | attribute :hp, default: 0.0 7 | attribute :real, default: 0.0 8 | attribute :imag, default: 0.0 9 | attribute :angle, default: 0.0 10 | attribute :state, default: 0 11 | end 12 | 13 | # Correlation Cycle Index 14 | # The very definition of a trend mode and a cycle mode makes it simple 15 | # to create a state variable that identifies the market state. If the 16 | # state is zero, the market is in a cycle mode. If the state is +1 the 17 | # market is in a trend up. If the state is -1 the market is in a trend down. 18 | # 19 | # SOURCE: https://www.mesasoftware.com/papers/CORRELATION%20AS%20A%20CYCLE%20INDICATOR.pdf 20 | class Cci < Indicator 21 | register name: :cci 22 | 23 | def max_period 24 | [min_period, dc_period].max 25 | end 26 | 27 | def compute_correlations 28 | corr_real = Statistics::Correlation.new 29 | corr_imag = Statistics::Correlation.new 30 | arc = 2.0 * Math::PI / max_period.to_f 31 | (0...max_period).each do |period| 32 | radians = arc * period 33 | prev_hp = p(period).hp 34 | corr_real.add(prev_hp, Math.cos(radians)) 35 | corr_imag.add(prev_hp, -Math.sin(radians)) 36 | end 37 | p0.real = corr_real.coefficient 38 | p0.imag = corr_imag.coefficient 39 | end 40 | 41 | def compute_angle 42 | # Compute the angle as an arctangent and resolve the quadrant 43 | p0.angle = 90 + rad2deg(Math.atan(p0.real / p0.imag)) 44 | p0.angle -= 180 if p0.imag > 0 45 | 46 | # Do not allow the rate change of angle to go negative 47 | p0.angle = p1.angle if (p0.angle < p1.angle) && (p1.angle - p0.angle) < 270 48 | end 49 | 50 | def compute_state 51 | return unless (p0.angle - p1.angle).abs < 9 52 | 53 | p0.state = p0.angle < 0 ? -1 : 1 54 | end 55 | 56 | def compute 57 | p0.hp = two_pole_butterworth :input, previous: :hp, period: min_period 58 | 59 | compute_correlations 60 | compute_angle 61 | compute_state 62 | end 63 | end 64 | end 65 | end 66 | -------------------------------------------------------------------------------- /lib/quant/mixins/functions.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Quant 4 | module Mixins 5 | module Functions 6 | # α = Cos(K*360/Period)+Sin(K*360/Period)−1 / Cos(K*360/Period) 7 | # k = 1.0 for single-pole filters 8 | # k = 0.707 for two-pole high-pass filters 9 | # k = 1.414 for two-pole low-pass filters 10 | def period_to_alpha(period, k: 1.0) 11 | radians = deg2rad(k * 360 / period.to_f) 12 | cos = Math.cos(radians) 13 | sin = Math.sin(radians) 14 | (cos + sin - 1) / cos 15 | end 16 | 17 | # 3 bars = 0.5 18 | # 4 bars = 0.4 19 | # 5 bars = 0.333 20 | # 6 bars = 0.285 21 | # 10 bars = 0.182 22 | # 20 bars = 0.0952 23 | # 40 bars = 0.0488 24 | # 50 bars = 0.0392 25 | def bars_to_alpha(bars) 26 | 2.0 / (bars + 1) 27 | end 28 | 29 | def deg2rad(degrees) 30 | degrees * Math::PI / 180.0 31 | end 32 | 33 | def rad2deg(radians) 34 | radians * 180.0 / Math::PI 35 | end 36 | 37 | # dx1 = x2-x1; 38 | # dy1 = y2-y1; 39 | # dx2 = x4-x3; 40 | # dy2 = y4-y3; 41 | 42 | # d = dx1*dx2 + dy1*dy2; // dot product of the 2 vectors 43 | # l2 = (dx1*dx1+dy1*dy1)*(dx2*dx2+dy2*dy2) // product of the squared lengths 44 | def angle(line1, line2) 45 | dx1 = line2[0][0] - line1[0][0] 46 | dy1 = line2[0][1] - line1[0][1] 47 | dx2 = line2[1][0] - line1[1][0] 48 | dy2 = line2[1][1] - line1[1][1] 49 | 50 | d = dx1 * dx2 + dy1 * dy2 51 | l2 = ((dx1**2 + dy1**2) * (dx2**2 + dy2**2)) 52 | 53 | radians = d.to_f / Math.sqrt(l2) 54 | value = rad2deg Math.acos(radians) 55 | 56 | value.nan? ? 0.0 : value 57 | end 58 | 59 | # angle = acos(d/sqrt(l2)) 60 | # public static double angleBetween2Lines(Line2D line1, Line2D line2) 61 | # { 62 | # double angle1 = Math.atan2(line1.getY1() - line1.getY2(), 63 | # line1.getX1() - line1.getX2()); 64 | # double angle2 = Math.atan2(line2.getY1() - line2.getY2(), 65 | # line2.getX1() - line2.getX2()); 66 | # return angle1-angle2; 67 | # } 68 | end 69 | end 70 | end 71 | -------------------------------------------------------------------------------- /spec/lib/quant/indicators/decycler_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe Quant::Indicators::Decycler do 4 | using Quant 5 | 6 | let(:filename) { fixture_filename("DEUCES-sample.txt", :series) } 7 | let(:series) { Quant::Series.from_file(filename:, symbol: "DEUCES", interval: "1d") } 8 | let(:source) { :oc2 } 9 | 10 | subject { described_class.new(series:, source:) } 11 | 12 | it { is_expected.to be_a(described_class) } 13 | it { expect(subject.series.size).to eq(4) } 14 | it { expect(subject.values.map(&:input)).to eq([3.0, 6.0, 12.0, 24.0]) } 15 | it { expect(subject.values.map{ |v| v.hp1.round(3) }).to eq([0.0, 1.996, 4.518, 8.903]) } 16 | it { expect(subject.values.map{ |v| v.hp2.round(3) }).to eq([0.0, 2.588, 7.025, 15.32]) } 17 | it { expect(subject.values.map{ |v| v.osc.round(3) }).to eq([0.0, 0.591, 2.507, 6.417]) } 18 | it { expect(subject.values.map{ |v| v.peak.round(3) }).to eq([0.0, 0.591, 2.507, 6.417]) } 19 | it { expect(subject.values.map{ |v| v.agc.round(3) }).to eq([0.0, 1.0, 1.0, 1.0]) } 20 | 21 | context "sine series" do 22 | let(:source) { :oc2 } 23 | let(:period) { 40 } 24 | let(:cycles) { 5 } 25 | let(:uniq_data_points) { cycles * period / cycles } # sine is cyclical, so we expect a few unique data points 26 | let(:series) do 27 | # period bar sine wave 28 | Quant::Series.new(symbol: "SINE", interval: "1d").tap do |series| 29 | cycles.times do 30 | (0...period).each do |degree| 31 | radians = degree * 2 * Math::PI / period 32 | series << 5.0 * Math.sin(radians) + 10.0 33 | end 34 | end 35 | end 36 | end 37 | 38 | it { expect(subject.series.size).to eq(cycles * period) } 39 | 40 | it { expect(subject.values.last(5).map{ |v| v.agc.round(3) }).to eq([0.609, 0.757, 0.889, 1.0, 1.0]) } 41 | it { expect(subject.values.first(5).map{ |v| v.agc.round(3) }).to eq([0.0, 1.0, 1.0, 1.0, 1.0]) } 42 | it { expect(subject.values.map{ |v| v.agc.round(1) }.uniq).to eq([0.0, 1.0, 0.9, 0.7, 0.5, 0.3, 0.1, -0.2, -0.5, -0.7, -1.0, -0.9, -0.8, -0.4, 0.2, 0.8, 0.4, -0.1, -0.3, -0.6, 0.6]) } 43 | it { expect(subject.values.map{ |v| v.ift.round(1) }.uniq).to eq([0.0, 1.0, 0.9, 0.3, -0.8, -1.0, -0.1, 0.7, 0.8, -0.5, -0.9, -0.3, 0.5]) } 44 | end 45 | end 46 | -------------------------------------------------------------------------------- /spec/lib/quant/mixins/functions_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe Quant::Mixins::Functions do 4 | let(:klass) do 5 | Class.new do 6 | include Quant::Mixins::Functions 7 | end 8 | end 9 | 10 | subject { klass.new } 11 | 12 | describe "#period_to_alpha" do 13 | it { expect(subject.period_to_alpha(4.0)).to eq(0.0) } 14 | it { expect(subject.period_to_alpha(5.0)).to eq(0.841615559675464) } 15 | it { expect(subject.period_to_alpha(6.0)).to eq(0.7320508075688775) } 16 | it { expect(subject.period_to_alpha(10.0)).to eq(0.4904745505055712) } 17 | it { expect(subject.period_to_alpha(20.0)).to eq(0.273457471994639) } 18 | it { expect(subject.period_to_alpha(40.0)).to eq(0.14591931453653348) } 19 | it { expect(subject.period_to_alpha(50.0)).to eq(0.11838140763681106) } 20 | end 21 | 22 | describe "#bars_to_alpha" do 23 | it { expect(subject.bars_to_alpha(3)).to eq(0.5) } 24 | it { expect(subject.bars_to_alpha(4)).to eq(0.4) } 25 | it { expect(subject.bars_to_alpha(5)).to eq(0.3333333333333333) } 26 | it { expect(subject.bars_to_alpha(6)).to eq(0.2857142857142857) } 27 | it { expect(subject.bars_to_alpha(10)).to eq(0.18181818181818182) } 28 | it { expect(subject.bars_to_alpha(20)).to eq(0.09523809523809523) } 29 | it { expect(subject.bars_to_alpha(40)).to eq(0.04878048780487805) } 30 | it { expect(subject.bars_to_alpha(50)).to eq(0.0392156862745098) } 31 | end 32 | 33 | describe "#deg2rad" do 34 | it { expect(subject.deg2rad(90)).to eq(Math::PI * 0.5) } 35 | it { expect(subject.deg2rad(180)).to eq(Math::PI) } 36 | it { expect(subject.deg2rad(360)).to eq(Math::PI * 2) } 37 | end 38 | 39 | describe "#rad2deg" do 40 | it { expect(subject.rad2deg(Math::PI * 0.5)).to eq(90) } 41 | it { expect(subject.rad2deg(Math::PI)).to eq(180) } 42 | it { expect(subject.rad2deg(Math::PI * 2)).to eq(360) } 43 | end 44 | 45 | describe "#angle" do 46 | it { expect(subject.angle([[0, 0], [1, 1]], [[0, 0], [1, 1]])).to eq(0.0) } 47 | it { expect(subject.angle([[0, 0], [1, 1]], [[1, 1], [2, 2]])).to eq(0.0) } 48 | it { expect(subject.angle([[0, 0], [1, 1]], [[1, 1], [2, 0]])).to eq(90.0) } 49 | it { expect(subject.angle([[0, 0], [0, 1]], [[1, 1], [1, 1]]).round(2)).to eq(45.0) } 50 | end 51 | end 52 | -------------------------------------------------------------------------------- /lib/quant/indicators/pivots/camarilla.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Quant 4 | module Indicators 5 | module Pivots 6 | # Camarilla pivot point calculations are rather straightforward. We need to 7 | # input the previous day’s open, high, low and close. The formulas for each 8 | # resistance and support level are: 9 | # 10 | # R4 = Closing + ((High -Low) x 1.5000) 11 | # R3 = Closing + ((High -Low) x 1.2500) 12 | # R2 = Closing + ((High -Low) x 1.1666) 13 | # R1 = Closing + ((High -Low x 1.0833) 14 | # PP = (High + Low + Closing) / 3 15 | # S1 = Closing – ((High -Low) x 1.0833) 16 | # S2 = Closing – ((High -Low) x 1.1666) 17 | # S3 = Closing – ((High -Low) x 1.2500) 18 | # S4 = Closing – ((High-Low) x 1.5000) 19 | # 20 | # R5 = R4 + 1.168 * (R4 – R3) 21 | # R6 = (High/Low) * Close 22 | # S5 = S4 – 1.168 * (S3 – S4) 23 | # S6 = Close – (R6 – Close) 24 | # 25 | # The calculation for further resistance and support levels varies from this 26 | # norm. These levels can come into play during strong trend moves, so it’s 27 | # important to understand how to identify them. For example, R5, R6, S5 and S6 28 | # are calculated as follows: 29 | # 30 | # source: https://tradingstrategyguides.com/camarilla-pivot-trading-strategy/ 31 | class Camarilla < Pivot 32 | register name: :camarilla 33 | 34 | def compute_midpoint 35 | p0.midpoint = t0.hlc3 36 | end 37 | 38 | def compute_bands 39 | p0.h1 = t0.close_price + p0.range * 1.083 40 | p0.l1 = t0.close_price - p0.range * 1.083 41 | 42 | p0.h2 = t0.close_price + p0.range * 1.167 43 | p0.l2 = t0.close_price - p0.range * 1.167 44 | 45 | p0.h3 = t0.close_price + p0.range * 1.250 46 | p0.l3 = t0.close_price - p0.range * 1.250 47 | 48 | p0.h4 = t0.close_price + p0.range * 1.500 49 | p0.l4 = t0.close_price - p0.range * 1.500 50 | 51 | p0.h5 = p0.h4 + 1.68 * (p0.h4 - p0.h3) 52 | p0.l5 = p0.l4 - 1.68 * (p0.l3 - p0.l4) 53 | 54 | p0.h6 = (t0.high_price / t0.low_price) * t0.close_price 55 | p0.l6 = t0.close_price - (p0.h6 - t0.close_price) 56 | end 57 | end 58 | end 59 | end 60 | end 61 | -------------------------------------------------------------------------------- /spec/lib/quant/indicators/mama_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe Quant::Indicators::Mama do 4 | let(:apple_fixture_filename) { fixture_filename("AAPL-19990104_19990107.txt", :series) } 5 | let(:series) { Quant::Series.from_file(filename: apple_fixture_filename, symbol: "AAPL", interval: :daily) } 6 | let(:source) { :oc2 } 7 | 8 | subject { described_class.new(series:, source:) } 9 | 10 | it { is_expected.to be_a(described_class) } 11 | it { expect(subject.series.size).to eq(4) } 12 | it { expect(subject.values.map{ |v| v.mama.round(3) }).to eq([0.372, 0.375, 0.378, 0.382]) } 13 | it { expect(subject.values.map{ |v| v.fama.round(3) }).to eq([0.372, 0.373, 0.374, 0.375]) } 14 | it { expect(subject.values.map{ |v| v.gama.round(3) }).to eq([0.372, 0.373, 0.375, 0.377]) } 15 | it { expect(subject.values.map{ |v| v.dama.round(3) }).to eq([0.372, 0.372, 0.373, 0.373]) } 16 | it { expect(subject.values.map{ |v| v.lama.round(3) }).to eq([0.372, 0.372, 0.372, 0.373]) } 17 | it { expect(subject.values.map{ |v| v.faga.round(3) }).to eq([0.372, 0.372, 0.372, 0.372]) } 18 | it { expect(subject.values.map{ |v| v.osc.round(3) }).to eq([0.0, 0.002, 0.004, 0.007]) } 19 | it { expect(subject.values.map(&:crossed)).to all be(:unchanged) } 20 | 21 | context "sine series" do 22 | let(:period) { 40 } 23 | let(:cycles) { 4 } 24 | let(:uniq_data_points) { cycles * period / cycles } # sine is cyclical, so we expect a few unique data points 25 | let(:series) do 26 | # period bar sine wave 27 | Quant::Series.new(symbol: "SINE", interval: "1d").tap do |series| 28 | cycles.times do 29 | (0...period).each do |degree| 30 | radians = degree * 2 * Math::PI / period 31 | series << 5.0 * Math.sin(radians) + 10.0 32 | end 33 | end 34 | end 35 | end 36 | 37 | it { expect(subject.series.size).to eq(160) } 38 | it { expect(subject.values.map{ |m| m.mama.round(1) }.uniq.size).to be_within(10).of(uniq_data_points) } 39 | 40 | it "crosses 2x cycles" do 41 | grouped_crossings = subject.values.map(&:crossed).group_by(&:itself).transform_values(&:count) 42 | unchanged_count = period * cycles - cycles * 2 43 | expect(grouped_crossings).to eq({ down: cycles, unchanged: unchanged_count, up: cycles }) 44 | end 45 | end 46 | end 47 | -------------------------------------------------------------------------------- /spec/lib/quant/indicators/mesa_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe Quant::Indicators::Mesa do 4 | let(:apple_fixture_filename) { fixture_filename("AAPL-19990104_19990107.txt", :series) } 5 | let(:series) { Quant::Series.from_file(filename: apple_fixture_filename, symbol: "AAPL", interval: :daily) } 6 | let(:source) { :oc2 } 7 | 8 | subject { described_class.new(series:, source:) } 9 | 10 | it { is_expected.to be_a(described_class) } 11 | it { expect(subject.series.size).to eq(4) } 12 | it { expect(subject.values.map{ |v| v.mama.round(3) }).to eq([0.372, 0.376, 0.379, 0.384]) } 13 | it { expect(subject.values.map{ |v| v.lama.round(3) }).to eq([0.372, 0.372, 0.373, 0.373]) } 14 | it { expect(subject.values.map{ |v| v.gama.round(3) }).to eq([0.372, 0.374, 0.376, 0.380]) } 15 | it { expect(subject.values.map{ |v| v.dama.round(3) }).to eq([0.372, 0.372, 0.373, 0.373]) } 16 | it { expect(subject.values.map{ |v| v.lama.round(3) }).to eq([0.372, 0.372, 0.373, 0.373]) } 17 | it { expect(subject.values.map{ |v| v.faga.round(3) }).to eq([0.372, 0.372, 0.372, 0.372]) } 18 | it { expect(subject.values.map{ |v| v.osc.round(3) }).to eq([0.0, 0.003, 0.005, 0.007]) } 19 | it { expect(subject.values.map(&:crossed)).to all be(:unchanged) } 20 | 21 | context "sine series" do 22 | let(:period) { 40 } 23 | let(:cycles) { 4 } 24 | let(:uniq_data_points) { cycles * period / (cycles - 1) } # sine is cyclical, so we expect a few unique data points 25 | let(:series) do 26 | # period bar sine wave 27 | Quant::Series.new(symbol: "SINE", interval: "1d").tap do |series| 28 | cycles.times do 29 | (0...period).each do |degree| 30 | radians = degree * 2 * Math::PI / period 31 | series << 5.0 * Math.sin(radians) + 10.0 32 | end 33 | end 34 | end 35 | end 36 | 37 | it { expect(subject.series.size).to eq(160) } 38 | it { expect(subject.values.map{ |m| m.mama.round(1) }.uniq.size).to be_within(10).of(uniq_data_points) } 39 | 40 | it "crosses 2x cycles" do 41 | grouped_crossings = subject.values.map(&:crossed).group_by(&:itself).transform_values(&:count) 42 | unchanged_count = period * cycles - cycles * 2 43 | expect(grouped_crossings).to eq({ down: cycles, unchanged: unchanged_count, up: cycles }) 44 | end 45 | end 46 | end 47 | -------------------------------------------------------------------------------- /spec/performance/optimal_compute.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "bundler/setup" 4 | require "benchmark/ips" 5 | require "quantitative" 6 | 7 | # This test ensures we are only computing each indicator, at most, once per tick per input source. 8 | # Indicators that utilized dominant cycle indicators originally were computing the dominant cycle 9 | # for each and every indicator. This test was used to count number of calls to the compute! 10 | # method and compare against number of ticks in the series. 11 | 12 | class ComputeInspector 13 | def self.instance 14 | @instance ||= new 15 | end 16 | 17 | def self.stats 18 | instance.stats 19 | end 20 | 21 | attr_reader :stats 22 | 23 | def initialize 24 | @stats = Hash.new { |h,k| h[k] = 0 } 25 | end 26 | 27 | def self.start 28 | instance.start 29 | end 30 | 31 | def self.finish 32 | instance.finish 33 | end 34 | 35 | def start 36 | @stats.clear 37 | @trace = TracePoint.new(:call) do |tp| 38 | next unless tp.defined_class.to_s =~ /Quant/ 39 | next unless %i(assign_series! compute).include? tp.method_id 40 | 41 | key = "#{tp.defined_class}##{tp.method_id}:#{tp.lineno}" 42 | @stats[key] += 1 43 | end 44 | @trace.enable 45 | end 46 | 47 | def finish 48 | @trace.disable 49 | puts "-" * 80 50 | pp @stats 51 | puts "-" * 80 52 | end 53 | end 54 | 55 | symbol = "AAPL" 56 | fixtures_folder = File.expand_path File.join(File.dirname(__FILE__), "..", "fixtures", "series") 57 | filename = File.join(fixtures_folder, "AAPL-19990104_19990107.txt") 58 | unless File.exist?(filename) 59 | puts "file #{filename} does not exist" 60 | exit 61 | end 62 | 63 | puts "loading #{filename}..." 64 | 65 | ComputeInspector.start 66 | 67 | series = Quant::Series.from_file(filename:, symbol:, interval: "1d") 68 | series.indicators.oc2.dominant_cycle.map(&:itself) 69 | series.indicators.oc2.mesa.map(&:itself) 70 | series.indicators.oc2.ping.map(&:itself) 71 | series.limit_iterations(0, 2).indicators.oc2.atr.map(&:itself) 72 | 73 | ComputeInspector.finish 74 | 75 | if ComputeInspector.stats.values.all? { |count| count == series.ticks.size } 76 | puts "SUCCESS: All indicators computed once per tick!" 77 | else 78 | puts "ERROR: Indicators computed more than once per tick!" 79 | end 80 | puts "Ticks loaded: %i" % series.ticks.size 81 | -------------------------------------------------------------------------------- /lib/quant/indicators/rsi.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Quant 4 | module Indicators 5 | class RsiPoint < IndicatorPoint 6 | attribute :hp, default: 0.0 7 | attribute :filter, default: 0.0 8 | 9 | attribute :delta, default: 0.0 10 | attribute :gain, default: 0.0 11 | attribute :loss, default: 0.0 12 | 13 | attribute :gains, default: 0.0 14 | attribute :losses, default: 0.0 15 | attribute :denom, default: 0.0 16 | 17 | attribute :inst_rsi, default: 0.0 18 | attribute :rsi, default: 0.0 19 | end 20 | 21 | # The Relative Strength Index (RSI) is a momentum oscillator that measures the 22 | # speed and change of price movements. This RSI indicator is adaptive and 23 | # uses the half-period of the dominant cycle to calculate the RSI. 24 | # It is further smoothed by an exponential moving average of the last three bars 25 | # (or whatever the micro_period is set to). 26 | # 27 | # The RSI oscillates between 0 and 1. Traditionally, and in this implementation, 28 | # the RSI is considered overbought when above 0.7 and oversold when below 0.3. 29 | class Rsi < Indicator 30 | register name: :rsi 31 | 32 | def quarter_period 33 | half_period / 2 34 | end 35 | 36 | def half_period 37 | (dc_period / 2) - 1 38 | end 39 | 40 | def compute 41 | # The High Pass filter is half the dominant cycle period while the 42 | # Low Pass Filter (super smoother) is the quarter dominant cycle period. 43 | p0.hp = high_pass_filter :input, period: half_period 44 | p0.filter = ema :hp, previous: :filter, period: quarter_period 45 | 46 | lp = p(half_period) 47 | p0.delta = p0.filter - lp.filter 48 | p0.delta > 0.0 ? p0.gain = p0.delta : p0.loss = p0.delta.abs 49 | 50 | period_points(half_period).tap do |period_points| 51 | p0.gains = period_points.map(&:gain).sum 52 | p0.losses = period_points.map(&:loss).sum 53 | end 54 | 55 | p0.denom = p0.gains + p0.losses 56 | 57 | if p0.denom > 0.0 58 | p0.inst_rsi = (p0.gains / p0.denom) 59 | p0.rsi = ema :inst_rsi, previous: :rsi, period: micro_period 60 | else 61 | p0.inst_rsi = 0.5 62 | p0.rsi = 0.5 63 | end 64 | end 65 | end 66 | end 67 | end -------------------------------------------------------------------------------- /lib/quant/time_methods.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Quant 4 | module_function 5 | 6 | # The library is designed to work with UTC time. This method provides a single point of 7 | # access for the current time. This is useful for testing and for retrieving the current time in 8 | # one place for the entire library. 9 | def current_time 10 | Time.now.utc 11 | end 12 | 13 | # This method, similar to +current_time+, provides a single point of access for the current date. 14 | def current_date 15 | Date.today 16 | end 17 | 18 | module TimeMethods 19 | # Provides lower-bounds for dates and times. See +Quant::TimePeriod+ for example use-case. 20 | EPOCH_DATE = Date.civil(1492, 10, 12).freeze # arbitrary! (blame #co-pilot) 21 | EPOCH_TIME = Time.new(EPOCH_DATE.year, EPOCH_DATE.month, EPOCH_DATE.day, 0, 0, 0, "+00:00").utc.freeze 22 | 23 | # The epoch date is a NULL object +Date+ for the library. It is used to represent the 24 | # beginning of time. That is, a date that is without bound and helps avoid +nil+ checks, 25 | # +NULL+ database entries, and such when working with dates. 26 | def self.epoch_date 27 | EPOCH_DATE 28 | end 29 | 30 | # The epoch time is a NULL object +Time+ for the library. It is used to represent the 31 | # beginning of time. That is, a time that is without bound and helps avoid +nil+ checks, 32 | # +NULL+ database entries, and such when working with time. 33 | def self.epoch_time 34 | EPOCH_TIME 35 | end 36 | 37 | # When streaming or extracting a time entry from a payload, Time can already be parsed into a +Time+ object. 38 | # Or it may be an +Integer+ representing the number of seconds since the epoch. Or it may be a +String+ that 39 | # can be parsed into a +Time+ object. This method normalizes the time into a +Time+ object on the UTC timezone. 40 | def extract_time(value) 41 | case value 42 | when Time 43 | value.utc 44 | when DateTime 45 | Time.new(value.year, value.month, value.day, value.hour, value.minute, value.second).utc 46 | when Date 47 | Time.utc(value.year, value.month, value.day, 0, 0, 0) 48 | when Integer 49 | Time.at(value).utc 50 | when String 51 | Time.parse(value).utc 52 | else 53 | raise ArgumentError, "Invalid time: #{value.inspect}" 54 | end 55 | end 56 | end 57 | end 58 | -------------------------------------------------------------------------------- /spec/lib/quant/time_methods_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe Quant::TimeMethods do 4 | let(:time_test_class) do 5 | Class.new do 6 | include Quant::TimeMethods 7 | end 8 | end 9 | 10 | let(:instance) { time_test_class.new } 11 | 12 | describe "#extract_time" do 13 | let(:expected_time) { Time.new(2023, 11, 12, 8, 31, 25).utc } 14 | 15 | subject { instance.extract_time(time) } 16 | 17 | context "when nil" do 18 | let(:time) { nil } 19 | it { expect { subject }.to raise_error(ArgumentError, "Invalid time: nil") } 20 | end 21 | 22 | context "when a Time" do 23 | let(:time) { expected_time } 24 | it { is_expected.to eq(expected_time) } 25 | 26 | context "when current_time" do 27 | let(:time) { Quant.current_time } 28 | it { is_expected.to be_within(1).of(Time.now) } 29 | end 30 | 31 | context "when epoch_time" do 32 | let(:time) { described_class.epoch_time } 33 | it { is_expected.to be < (Time.at(0)) } 34 | end 35 | end 36 | 37 | context "when a Date" do 38 | let(:time) { Date.civil(2023, 11, 12) } 39 | it { is_expected.to eq(Time.utc(2023, 11, 12, 0, 0, 0)) } 40 | 41 | context "when current_date" do 42 | let(:time) { Quant.current_date } 43 | it { is_expected.to eq(Time.utc(time.year, time.month, time.day, 0, 0, 0)) } 44 | end 45 | 46 | context "when epoch_date" do 47 | let(:time) { described_class.epoch_date } 48 | it { is_expected.to be < (Time.at(0)) } 49 | end 50 | end 51 | 52 | context "when an DateTime" do 53 | let(:time) { DateTime.new(2023, 11, 12, 8, 31, 25, 0, 0) } 54 | it { is_expected.to eq(expected_time) } 55 | end 56 | 57 | context "when an Integer" do 58 | let(:time) { expected_time.to_i } 59 | it { is_expected.to eq(expected_time) } 60 | end 61 | 62 | context "when a String" do 63 | context "without timezone" do 64 | let(:time) { "2023-11-12T08:31:25" } 65 | it { is_expected.to eq(expected_time) } 66 | end 67 | 68 | context "ET timezone" do 69 | let(:time) { "2023-11-12T08:31:25TZ+500" } 70 | it { is_expected.to eq(expected_time) } 71 | end 72 | 73 | context "UTC timezone" do 74 | let(:time) { "2023-11-12T08:31:25TZ" } 75 | it { is_expected.to eq(expected_time) } 76 | end 77 | end 78 | end 79 | end 80 | -------------------------------------------------------------------------------- /spec/lib/quant/attrs_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Experiment 4 | def self.registry 5 | @registry ||= {} 6 | end 7 | 8 | def self.register(klass, name, default) 9 | registry[klass] ||= {} 10 | registry[klass][name] = default 11 | end 12 | 13 | module InstanceMethods 14 | def initialize(...) 15 | initialize_attributes 16 | super(...) 17 | end 18 | 19 | def each_attribute(&block) 20 | klass = self.class 21 | loop do 22 | attributes = Experiment.registry[klass] 23 | break if attributes.nil? 24 | 25 | attributes.each{ |name, default| block.call(name, default) } 26 | klass = klass.superclass 27 | end 28 | end 29 | 30 | def initialize_attributes 31 | each_attribute do |name, default| 32 | ivar_name = "@#{name}" 33 | instance_variable_set(ivar_name, default) 34 | define_singleton_method(name) { instance_variable_get(ivar_name) } 35 | define_singleton_method("#{name}=") { |value| instance_variable_set(ivar_name, value) } 36 | end 37 | end 38 | 39 | def to_h 40 | {}.tap do |key_values| 41 | each_attribute do |name, _default| 42 | ivar_name = "@#{name}" 43 | value = instance_variable_get(ivar_name) 44 | key_values[name] = value if value 45 | end 46 | end 47 | end 48 | end 49 | 50 | module ClassMethods 51 | def bake(name, default) 52 | Experiment.register(self, name, default) 53 | end 54 | end 55 | 56 | def self.included(base) 57 | base.extend(ClassMethods) 58 | base.prepend(InstanceMethods) 59 | end 60 | end 61 | 62 | class Reaction 63 | include Experiment 64 | 65 | bake(:foo, "f") 66 | end 67 | 68 | class Salt < Reaction 69 | bake(:baz, "z") 70 | end 71 | 72 | class Pepper < Salt 73 | bake(:foobar, "foobar") 74 | end 75 | 76 | RSpec.describe "experiment" do 77 | it { expect(Reaction.new.to_h).to eq({ foo: "f" }) } 78 | it { expect(Salt.new.to_h).to eq({ baz: "z", foo: "f" }) } 79 | it { expect(Salt.new.tap{ |s| s.foo = "F" }.to_h).to eq({ baz: "z", foo: "F" }) } 80 | it { expect(Pepper.new.to_h).to eq({ baz: "z", foo: "f", foobar: "foobar" }) } 81 | 82 | # it { expect(Reaction.new.hello).to eq("hello") } 83 | # it { expect(Reaction.world).to eq("world") } 84 | # it { expect(Salt.world).to eq("world") } 85 | # it { Reaction.bar(:foo, "f"); expect(Reaction.foo).to eq({foo: "f"}) } 86 | # it { Reaction.bar(:foo, "f"); expect(Salt.foo).to eq({foo: "f", baz: "z"}) } 87 | end 88 | -------------------------------------------------------------------------------- /spec/lib/quant/mixins/stochastic_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module StochasticMixinTest 4 | class TestPoint < Quant::Indicators::IndicatorPoint 5 | attribute :st, default: 0.0 6 | end 7 | 8 | class TestIndicator < Quant::Indicators::Indicator 9 | include Quant::Mixins::Stochastic 10 | 11 | def points_class 12 | TestPoint 13 | end 14 | 15 | def compute 16 | p0.st = stochastic(:input, period: 12).round(3) 17 | end 18 | end 19 | 20 | RSpec.describe Quant::Mixins::Stochastic do 21 | let(:filename) { fixture_filename("DEUCES-sample.txt", :series) } 22 | let(:series) { Quant::Series.from_file(filename:, symbol: "DEUCES", interval: "1d") } 23 | 24 | subject { TestIndicator.new(series:, source: :oc2) } 25 | 26 | before { series.indicators.oc2.attach(indicator_class: TestIndicator, name: :stoch) } 27 | 28 | context "deuces sample prices" do 29 | it { is_expected.to be_a(TestIndicator) } 30 | it { expect(subject.ticks.size).to eq(subject.series.size) } 31 | it { expect(subject.values.map(&:input)).to eq([3.0, 6.0, 12.0, 24.0]) } 32 | it { expect(subject.values.map(&:st)).to eq([0.0, 100.0, 100.0, 100.0]) } 33 | end 34 | 35 | context "growing price" do 36 | let(:series) { Quant::Series.new(symbol: "ST", interval: "1d") } 37 | 38 | [[1, 0.0], 39 | [2, 100], 40 | [3, 100], 41 | [4, 100], 42 | [5, 100], 43 | [6, 100], 44 | ].each do |n, expected| 45 | dataset = (1..n).to_a 46 | 47 | it "is #{expected.inspect} when series: is #{dataset.inspect}" do 48 | dataset.each { |price| series << price } 49 | expect(subject.p0.st).to eq expected 50 | end 51 | end 52 | end 53 | 54 | context "random" do 55 | let(:series) { Quant::Series.new(symbol: "ST", interval: "1d") } 56 | 57 | it "climbs and falls with series" do 58 | [36, 32, 69, 47, 28, 30, 37, 39, 45].map { |price| series << price } 59 | expect(subject.values.map(&:st)).to eq([0.0, 0.0, 100.0, 40.541, 0.0, 4.878, 21.951, 26.829, 41.463]) 60 | end 61 | end 62 | context "static price" do 63 | using Quant 64 | 65 | let(:series) { Quant::Series.new(symbol: "ST", interval: "1d") } 66 | 67 | before { 25.times { series << 5.0 } } 68 | 69 | it { expect(subject.ticks.size).to eq(subject.series.size) } 70 | it { expect(subject.values.map(&:input)).to be_all(5.0) } 71 | it { expect(subject.values.map(&:st).uniq).to eq([0.0]) } 72 | end 73 | end 74 | end 75 | -------------------------------------------------------------------------------- /lib/quant/indicators/roofing.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Quant 4 | module Indicators 5 | # The ideal time to buy is when the cycle is at a trough, and the ideal time to exit a long position or to 6 | # sell short is when the cycle is at a peak.These conditions are flagged by the filter crossing itself 7 | # delayed by two bars, and are included as part of the indicator. 8 | class RoofingPoint < IndicatorPoint 9 | attribute :hp, default: 0.0 10 | attribute :value, default: 0.0 11 | attribute :peak, default: 0.0 12 | attribute :agc, default: 0.0 13 | attribute :direction, default: 0 14 | attribute :turned, default: false 15 | end 16 | 17 | class Roofing < Indicator 18 | register name: :roofing 19 | 20 | def low_pass_period 21 | dc_period 22 | end 23 | 24 | def high_pass_period 25 | low_pass_period * 2 26 | end 27 | 28 | # //Highpass filter cyclic components whose periods are shorter than 48 bars 29 | # alpha1 = (Cosine(.707*360 / HPPeriod) + Sine (.707*360 / HPPeriod) - 1) / Cosine(.707*360 / HPPeriod); 30 | # HP = (1 - alpha1 / 2)*(1 - alpha1 / 2)*(Close - 2*Close[1] + Close[2]) + 2*(1 - alpha1)*HP[1] - (1 - alpha1)* 31 | # (1 - alpha1)*HP[2]; 32 | # //Smooth with a Super Smoother Filter from equation 3-3 33 | # a1 = expvalue(-1.414*3.14159 / LPPeriod); 34 | # b1 = 2*a1*Cosine(1.414*180 / LPPeriod); 35 | # c2 = b1; 36 | # c3 = -a1*a1; 37 | # c1 = 1 - c2 - c3; 38 | # Filt = c1*(HP + HP[1]) / 2 + c2*Filt[1] + c3*Filt[2 39 | def compute 40 | a = Math.cos(0.707 * deg2rad(360) / high_pass_period) 41 | b = Math.sin(0.707 * deg2rad(360) / high_pass_period) 42 | alpha1 = (a + b - 1) / a 43 | 44 | p0.hp = (1 - alpha1 / 2)**2 * (p0.input - 2 * p1.input + p2.input) + 2 * (1 - alpha1) * p1.hp - (1 - alpha1)**2 * p2.hp 45 | a1 = Math.exp(-1.414 * Math::PI / low_pass_period) 46 | c2 = 2 * a1 * Math.cos(1.414 * deg2rad(180) / low_pass_period) 47 | c3 = -a1**2 48 | c1 = 1 - c2 - c3 49 | p0.value = c1 * (p0.hp + p1.hp) / 2 + c2 * p1.value + c3 * p2.value 50 | p0.direction = p0.value > p2.value ? 1 : -1 51 | p0.turned = p0.direction != p2.direction 52 | # Peak = .991 * Peak[1]; 53 | # If AbsValue(BP) > Peak Then Peak = AbsValue(BP); If Peak <> 0 Then Signal = BP / Peak; 54 | p0.peak = [p0.value.abs, 0.991 * p1.peak].max 55 | p0.agc = p0.peak == 0 ? 0 : p0.value / p0.peak 56 | end 57 | end 58 | end 59 | end -------------------------------------------------------------------------------- /spec/lib/quant/indicators/ema_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe Quant::Indicators::Ema do 4 | let(:filename) { fixture_filename("DEUCES-sample.txt", :series) } 5 | let(:series) { Quant::Series.from_file(filename:, symbol: "DEUCES", interval: "1d") } 6 | let(:source) { :oc2 } 7 | 8 | subject { described_class.new(series:, source:) } 9 | 10 | it { is_expected.to be_a(described_class) } 11 | it { expect(subject.series.size).to eq(4) } 12 | it { expect(subject.values.map(&:input)).to eq([3.0, 6.0, 12.0, 24.0]) } 13 | 14 | context "EMA" do 15 | it { expect(subject.values.map{ |v| v.ema_dc_period.round(3) }).to eq([3.0, 3.2, 3.787, 5.134]) } 16 | it { expect(subject.values.map{ |v| v.ema_half_dc_period.round(3) }).to eq([3.0, 3.4, 4.547, 7.14]) } 17 | it { expect(subject.values.map{ |v| v.ema_micro_period.round(3) }).to eq([3.0, 4.5, 8.25, 16.125]) } 18 | it { expect(subject.values.map{ |v| v.ema_min_period.round(3) }).to eq([3.0, 3.545, 5.083, 8.522]) } 19 | it { expect(subject.values.map{ |v| v.ema_half_period.round(3) }).to eq([3.0, 3.2, 3.787, 5.134]) } 20 | it { expect(subject.values.map{ |v| v.ema_max_period.round(3) }).to eq([3.0, 3.122, 3.485, 4.322]) } 21 | end 22 | 23 | context "SS" do 24 | it { expect(subject.values.map{ |v| v.ss_dc_period.round(3) }).to eq([3.0, 3.06, 3.242, 3.707]) } 25 | it { expect(subject.values.map{ |v| v.ss_half_dc_period.round(3) }).to eq([3.0, 3.22, 3.88, 5.504]) } 26 | it { expect(subject.values.map{ |v| v.ss_micro_period.round(3) }).to eq([3.0, 4.516, 9.065, 18.226]) } 27 | it { expect(subject.values.map{ |v| v.ss_min_period.round(3) }).to eq([3.0, 3.38, 4.519, 7.238]) } 28 | it { expect(subject.values.map{ |v| v.ss_half_period.round(3) }).to eq([3.0, 3.06, 3.242, 3.707]) } 29 | it { expect(subject.values.map{ |v| v.ss_max_period.round(3) }).to eq([3.0, 3.023, 3.094, 3.277]) } 30 | end 31 | 32 | context "oscillators" do 33 | it { expect(subject.values.map{ |v| v.osc_dc_period.round(3) }).to eq([0.0, -0.14, -0.545, -1.428]) } 34 | it { expect(subject.values.map{ |v| v.osc_half_dc_period.round(3) }).to eq([0.0, -0.18, -0.667, -1.636]) } 35 | it { expect(subject.values.map{ |v| v.osc_micro_period.round(3) }).to eq([0.0, 0.016, 0.815, 2.101]) } 36 | it { expect(subject.values.map{ |v| v.osc_min_period.round(3) }).to eq([0.0, -0.166, -0.563, -1.284]) } 37 | it { expect(subject.values.map{ |v| v.osc_half_period.round(3) }).to eq([0.0, -0.14, -0.545, -1.428]) } 38 | it { expect(subject.values.map{ |v| v.osc_max_period.round(3) }).to eq([0.0, -0.099, -0.391, -1.045]) } 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /spec/lib/quant/indicators/rocket_rsi_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe Quant::Indicators::RocketRsi do 4 | let(:filename) { fixture_filename("DEUCES-sample.txt", :series) } 5 | let(:series) { Quant::Series.from_file(filename:, symbol: "DEUCES", interval: "1d") } 6 | let(:source) { :oc2 } 7 | 8 | subject { described_class.new(series:, source:) } 9 | 10 | it { is_expected.to be_a(described_class) } 11 | it { expect(subject.series.size).to eq(4) } 12 | it { expect(subject.values.map(&:input)).to eq([3.0, 6.0, 12.0, 24.0]) } 13 | it { expect(subject.values.map{ |v| v.rsi.round(3) }).to eq([0.0, 1.0, 1.0, 1.0]) } 14 | 15 | context "sine series" do 16 | let(:source) { :oc2 } 17 | let(:period) { 40 } 18 | let(:cycles) { 5 } 19 | let(:uniq_data_points) { cycles * period / cycles } # sine is cyclical, so we expect a few unique data points 20 | let(:series) do 21 | # period bar sine wave 22 | Quant::Series.new(symbol: "SINE", interval: "1d").tap do |series| 23 | cycles.times do 24 | (0...period).each do |degree| 25 | radians = degree * 2 * Math::PI / period 26 | series << 5.0 * Math.sin(radians) + 10.0 27 | end 28 | end 29 | end 30 | end 31 | 32 | it { expect(subject.series.size).to eq(cycles * period) } 33 | 34 | context "when price is climbing" do 35 | it { expect(subject.values[-10, 5].map{ |v| v.input.round(3) }).to eq([5.0, 5.062, 5.245, 5.545, 5.955]) } 36 | it { expect(subject.values[-10, 5].map{ |v| v.rsi.round(3) }).to eq([-1.0, -1.0, -1.0, -1.0, -1.0]) } 37 | it { expect(subject.values[-5, 5].map{ |v| v.input.round(3) }).to eq([6.464, 7.061, 7.73, 8.455, 9.218]) } 38 | it { expect(subject.values[-5, 5].map{ |v| v.rsi.round(3) }).to eq([-1.0, -1.0, -1.0, -1.0, -1.0]) } 39 | end 40 | 41 | context "when price is in valley" do 42 | it { expect(subject.values[-12, 5].map{ |v| v.input.round(3) }).to eq([5.245, 5.062, 5.0, 5.062, 5.245]) } 43 | it { expect(subject.values[-12, 5].map{ |v| v.hp.round(3) }).to eq([5.546, 5.247, 5.066, 5.006, 5.07]) } 44 | it { expect(subject.values[-12, 5].map{ |v| v.rsi.round(3) }).to eq([-1.0, -1.0, -1.0, -1.0, -1.0]) } 45 | end 46 | 47 | context "when price is at peak" do 48 | it { expect(subject.values[-32, 5].map{ |v| v.input.round(3) }).to eq([14.755, 14.938, 15.0, 14.938, 14.755]) } 49 | it { expect(subject.values[-32, 5].map{ |v| v.hp.round(3) }).to eq([14.454, 14.753, 14.934, 14.994, 14.93]) } 50 | it { expect(subject.values[-32, 5].map{ |v| v.rsi.round(3) }).to eq([1.0, 1.0, 1.0, 1.0, 1.0]) } 51 | end 52 | end 53 | end 54 | -------------------------------------------------------------------------------- /spec/lib/quant/indicators/rsi_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe Quant::Indicators::Rsi do 4 | let(:filename) { fixture_filename("DEUCES-sample.txt", :series) } 5 | let(:series) { Quant::Series.from_file(filename:, symbol: "DEUCES", interval: "1d") } 6 | let(:source) { :oc2 } 7 | 8 | subject { described_class.new(series:, source:) } 9 | 10 | it { is_expected.to be_a(described_class) } 11 | it { expect(subject.series.size).to eq(4) } 12 | it { expect(subject.values.map(&:input)).to eq([3.0, 6.0, 12.0, 24.0]) } 13 | it { expect(subject.values.map{ |v| v.rsi.round(3) }).to eq([0.5, 0.75, 0.875, 0.938]) } 14 | 15 | context "sine series" do 16 | let(:source) { :oc2 } 17 | let(:period) { 40 } 18 | let(:cycles) { 5 } 19 | let(:uniq_data_points) { cycles * period / cycles } # sine is cyclical, so we expect a few unique data points 20 | let(:series) do 21 | # period bar sine wave 22 | Quant::Series.new(symbol: "SINE", interval: "1d").tap do |series| 23 | cycles.times do 24 | (0...period).each do |degree| 25 | radians = degree * 2 * Math::PI / period 26 | series << 5.0 * Math.sin(radians) + 10.0 27 | end 28 | end 29 | end 30 | end 31 | 32 | it { expect(subject.series.size).to eq(cycles * period) } 33 | 34 | context "when price is climbing" do 35 | it { expect(subject.values[-10, 5].map{ |v| v.input.round(3) }).to eq([5.0, 5.062, 5.245, 5.545, 5.955]) } 36 | it { expect(subject.values[-10, 5].map{ |v| v.rsi.round(3) }).to eq([0.65, 0.761, 0.852, 0.917, 0.959]) } 37 | it { expect(subject.values[-5, 5].map{ |v| v.input.round(3) }).to eq([6.464, 7.061, 7.73, 8.455, 9.218]) } 38 | it { expect(subject.values[-5, 5].map{ |v| v.rsi.round(3) }).to eq([0.979, 0.99, 0.995, 0.997, 0.999]) } 39 | end 40 | 41 | context "when price is in valley" do 42 | it { expect(subject.values[-12, 5].map{ |v| v.input.round(3) }).to eq([5.245, 5.062, 5.0, 5.062, 5.245]) } 43 | it { expect(subject.values[-12, 5].map{ |v| v.filter.round(3) }).to eq([0.19, 0.256, 0.316, 0.368, 0.411]) } 44 | it { expect(subject.values[-12, 5].map{ |v| v.rsi.round(3) }).to eq([0.395, 0.524, 0.65, 0.761, 0.852]) } 45 | end 46 | 47 | context "when price is at peak" do 48 | it { expect(subject.values[-32, 5].map{ |v| v.input.round(3) }).to eq([14.755, 14.938, 15.0, 14.938, 14.755]) } 49 | it { expect(subject.values[-32, 5].map{ |v| v.filter.round(3) }).to eq([-0.19, -0.256, -0.316, -0.368, -0.411]) } 50 | it { expect(subject.values[-32, 5].map{ |v| v.rsi.round(3) }).to eq([0.605, 0.476, 0.35, 0.239, 0.148]) } 51 | end 52 | end 53 | end 54 | -------------------------------------------------------------------------------- /lib/quant/indicators/atr.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Quant 4 | module Indicators 5 | class AtrPoint < IndicatorPoint 6 | attribute :tr, default: 0.0 7 | attribute :period, default: :min_period 8 | attribute :value, default: 0.0 9 | attribute :slow, default: 0.0 10 | attribute :fast, default: 0.0 11 | attribute :inst_stoch, default: 0.0 12 | attribute :stoch, default: 0.0 13 | attribute :stoch_up, default: false 14 | attribute :stoch_turned, default: false 15 | attribute :osc, default: 0.0 16 | attribute :crossed, default: :unchanged 17 | 18 | def crossed_up? 19 | @crossed == :up 20 | end 21 | 22 | def crossed_down? 23 | @crossed == :down 24 | end 25 | end 26 | 27 | class Atr < Indicator 28 | register name: :atr 29 | 30 | attr_reader :points 31 | 32 | def period 33 | dc_period / 2 34 | end 35 | 36 | def fast_alpha 37 | period_to_alpha(period) 38 | end 39 | 40 | def slow_alpha 41 | period_to_alpha(2 * period) 42 | end 43 | 44 | # Typically, the Average True Range (ATR) is based on 14 periods and can be calculated on an intraday, daily, weekly 45 | # or monthly basis. For this example, the ATR will be based on daily data. Because there must be a beginning, the first 46 | # TR value is simply the High minus the Low, and the first 14-day ATR is the average of the daily TR values for the 47 | # last 14 days. After that, Wilder sought to smooth the data by incorporating the previous period's ATR value. 48 | 49 | # Current ATR = [(Prior ATR x 13) + Current TR] / 14 50 | 51 | # - Multiply the previous 14-day ATR by 13. 52 | # - Add the most recent day's TR value. 53 | # - Divide the total by 14 54 | 55 | def compute 56 | p0.period = period 57 | p0.tr = (t1.high_price - t0.close_price).abs 58 | 59 | p0.value = three_pole_super_smooth :tr, period:, previous: :value 60 | 61 | p0.slow = (slow_alpha * p0.value) + ((1.0 - slow_alpha) * p1.slow) 62 | p0.fast = (fast_alpha * p0.value) + ((1.0 - fast_alpha) * p1.fast) 63 | 64 | p0.inst_stoch = stochastic :value, period: 65 | p0.stoch = three_pole_super_smooth(:inst_stoch, previous: :stoch, period:).clamp(0, 100) 66 | p0.stoch_up = p0.stoch >= 70 67 | p0.stoch_turned = p0.stoch_up && !p1.stoch_up 68 | compute_oscillator 69 | end 70 | 71 | def compute_oscillator 72 | p0.osc = p0.value - wma(:value) 73 | p0.crossed = :up if p0.osc >= 0 && p1.osc < 0 74 | p0.crossed = :down if p0.osc <= 0 && p1.osc > 0 75 | end 76 | end 77 | end 78 | end 79 | -------------------------------------------------------------------------------- /spec/lib/quant/mixins/weighted_moving_average_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module WmaMixinTest 4 | class TestPoint < Quant::Indicators::IndicatorPoint 5 | attribute :wma, default: 0.0 6 | attribute :ewma, default: 0.0 7 | end 8 | 9 | class TestIndicator < Quant::Indicators::Indicator 10 | include Quant::Mixins::WeightedMovingAverage 11 | 12 | def points_class 13 | TestPoint 14 | end 15 | 16 | def compute 17 | p0.wma = weighted_moving_average(:input) 18 | p0.ewma = ewma(:input) 19 | end 20 | end 21 | 22 | RSpec.describe Quant::Mixins::WeightedMovingAverage do 23 | let(:filename) { fixture_filename("DEUCES-sample.txt", :series) } 24 | let(:series) { Quant::Series.from_file(filename:, symbol: "DEUCES", interval: "1d") } 25 | 26 | subject { TestIndicator.new(series:, source: :oc2) } 27 | 28 | before { series.indicators.oc2.attach(indicator_class: TestIndicator, name: :wma) } 29 | 30 | context "deuces sample prices" do 31 | it { is_expected.to be_a(TestIndicator) } 32 | it { expect(subject.ticks.size).to eq(subject.series.size) } 33 | it { expect(subject.values.map(&:input)).to eq([3.0, 6.0, 12.0, 24.0]) } 34 | it { expect(subject.values.map(&:wma)).to eq([3.0, 4.2, 7.5, 14.7]) } 35 | it { expect(subject.values.map(&:ewma)).to eq([3.0, 3.75, 5.892857142857143, 10.714285714285714]) } 36 | end 37 | 38 | context "growing price" do 39 | let(:series) { Quant::Series.new(symbol: "WMA", interval: "1d") } 40 | 41 | [[1, 1.0, 1.0], 42 | [2, 1.4, 1.25], 43 | [3, 2.1, 1.7142857142857142], 44 | [4, 3.0, 2.357142857142857], 45 | [5, 4.0, 3.142857142857143], 46 | [6, 5.0, 4.035714285714286]].each do |n, expected_wma, expected_ewma| 47 | dataset = (1..n).to_a 48 | 49 | it "is #{expected_wma.inspect} when series: is #{dataset.inspect}" do 50 | dataset.each { |price| series << price } 51 | expect(subject.p0.wma).to eq expected_wma 52 | end 53 | 54 | it "is #{expected_ewma.inspect} when series: is #{dataset.inspect}" do 55 | dataset.each { |price| series << price } 56 | expect(subject.p0.ewma).to eq expected_ewma 57 | end 58 | end 59 | end 60 | 61 | context "static price" do 62 | let(:series) { Quant::Series.new(symbol: "WMA", interval: "1d") } 63 | 64 | before { 25.times { series << 5.0 } } 65 | 66 | it { expect(subject.ticks.size).to eq(subject.series.size) } 67 | it { expect(subject.values.map(&:input)).to be_all(5.0) } 68 | it { expect(subject.values.map(&:wma)).to be_all(5.0) } 69 | it { expect(subject.values.map(&:ewma)).to be_all(5.0) } 70 | end 71 | end 72 | end 73 | -------------------------------------------------------------------------------- /spec/lib/quant/indicators/roofing_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe Quant::Indicators::Roofing do 4 | let(:filename) { fixture_filename("DEUCES-sample.txt", :series) } 5 | let(:series) { Quant::Series.from_file(filename:, symbol: "DEUCES", interval: "1d") } 6 | let(:source) { :oc2 } 7 | 8 | subject { described_class.new(series:, source:) } 9 | 10 | it { is_expected.to be_a(described_class) } 11 | it { expect(subject.series.size).to eq(4) } 12 | it { expect(subject.values.map(&:input)).to eq([3.0, 6.0, 12.0, 24.0]) } 13 | it { expect(subject.values.map{ |v| v.hp.round(3) }).to eq([0.0, 2.783, 7.937, 17.881]) } 14 | it { expect(subject.values.map{ |v| v.value.round(3) }).to eq([0.0, 0.056, 0.311, 1.006]) } 15 | it { expect(subject.values.map{ |v| v.peak.round(3) }).to eq([0.0, 0.056, 0.311, 1.006]) } 16 | it { expect(subject.values.map{ |v| v.agc.round(3) }).to eq([0, 1.0, 1.0, 1.0]) } 17 | 18 | context "sine series" do 19 | let(:source) { :oc2 } 20 | let(:period) { 40 } 21 | let(:cycles) { 1 } 22 | let(:uniq_data_points) { cycles * period / cycles } # sine is cyclical, so we expect a few unique data points 23 | let(:series) do 24 | # period bar sine wave 25 | Quant::Series.new(symbol: "SINE", interval: "1d").tap do |series| 26 | cycles.times do 27 | (0...period).each do |degree| 28 | radians = degree * 2 * Math::PI / period 29 | series << 5.0 * Math.sin(radians) + 10.0 30 | end 31 | end 32 | end 33 | end 34 | 35 | it { expect(subject.series.size).to eq(cycles * period) } 36 | 37 | # TODO: direction and turned need further analysis to confirm correctness 38 | xit { expect(subject.values.map(&:direction).group_by(&:itself).transform_values(&:count)).to eq({ 1 => 19, -1 => 21 }) } 39 | xit { expect(subject.values.map(&:turned).group_by(&:itself).transform_values(&:count)).to eq({ false => 34, true => 6 }) } 40 | 41 | context "tail end of the series" do 42 | it { expect(subject.values.last(5).map{ |v| v.value.round(3) }).to eq([-2.721, -2.353, -1.926, -1.449, -0.933]) } 43 | it { expect(subject.values.last(5).map{ |v| v.peak.round(3) }).to eq([3.324, 3.294, 3.264, 3.235, 3.206]) } 44 | it { expect(subject.values.last(5).map{ |v| v.agc.round(3) }).to eq([-0.818, -0.714, -0.59, -0.448, -0.291]) } 45 | end 46 | 47 | context "head of the series" do 48 | it { expect(subject.values.first(5).map{ |v| v.value.round(3) }).to eq([0.0, 0.015, 0.066, 0.164, 0.309]) } 49 | it { expect(subject.values.first(5).map{ |v| v.peak.round(3) }).to eq([0.0, 0.015, 0.066, 0.164, 0.309]) } 50 | it { expect(subject.values.first(5).map{ |v| v.agc.round(3) }).to eq([0, 1.0, 1.0, 1.0, 1.0]) } 51 | end 52 | end 53 | end 54 | -------------------------------------------------------------------------------- /spec/lib/quant/config_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe Quant::Config do 4 | after(:all) { Quant.default_configuration! } 5 | 6 | describe "configuring indicators" do 7 | subject { Quant.config.indicators } 8 | 9 | describe "Quant.config.indicators" do 10 | it { is_expected.to be_a(Quant::Settings::Indicators) } 11 | it { expect(subject.max_period).to eq(Quant::Settings::MAX_PERIOD) } 12 | it { expect(subject.min_period).to eq(Quant::Settings::MIN_PERIOD) } 13 | it { expect(subject.half_period).to eq(Quant::Settings::HALF_PERIOD) } 14 | it { expect(subject.pivot_kind).to eq(Quant::Settings::PIVOT_KINDS.first) } 15 | it { expect(subject.dominant_cycle_kind).to eq(Quant::Settings::DOMINANT_CYCLE_KINDS.first) } 16 | end 17 | 18 | describe "Quant.configure_indicators" do 19 | context "by method arguments" do 20 | before do 21 | Quant.configure_indicators \ 22 | max_period: 10, 23 | min_period: 4, 24 | micro_period: 2, 25 | pivot_kind: :fibbonacci 26 | end 27 | 28 | it { expect(subject.max_period).to eq(10) } 29 | it { expect(subject.min_period).to eq(4) } 30 | it { expect(subject.half_period).to eq(7) } 31 | it { expect(subject.micro_period).to eq(2) } 32 | it { expect(subject.pivot_kind).to eq(:fibbonacci) } 33 | it { expect(subject.dominant_cycle_kind).to eq(:half_period) } 34 | end 35 | 36 | context "by block" do 37 | before do 38 | Quant.configure_indicators do |config| 39 | config.max_period = 4 40 | config.min_period = 2 41 | config.micro_period = 4 42 | config.pivot_kind = :bollinger 43 | end 44 | end 45 | 46 | it { expect(subject.max_period).to eq(4) } 47 | it { expect(subject.min_period).to eq(2) } 48 | it { expect(subject.half_period).to eq(3) } 49 | it { expect(subject.micro_period).to eq(4) } 50 | it { expect(subject.dominant_cycle_kind).to eq(:half_period) } 51 | it { expect(subject.pivot_kind).to eq(:bollinger) } 52 | 53 | it "configures twice" do 54 | Quant.configure_indicators do |config| 55 | config.max_period = 12 56 | config.min_period = 6 57 | config.pivot_kind = :fibbonacci 58 | config.dominant_cycle_kind = :auto_correlation_reversal 59 | end 60 | 61 | expect(subject.max_period).to eq(12) 62 | expect(subject.min_period).to eq(6) 63 | expect(subject.half_period).to eq(9) 64 | expect(subject.micro_period).to eq(4) 65 | expect(subject.dominant_cycle_kind).to eq(:auto_correlation_reversal) 66 | expect(subject.pivot_kind).to eq(:fibbonacci) 67 | end 68 | end 69 | end 70 | end 71 | end 72 | -------------------------------------------------------------------------------- /spec/lib/quant/mixins/butterworth_filters_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ButterworthMixinTest 4 | class TestPoint < Quant::Indicators::IndicatorPoint 5 | attribute :bw2p, default: 0.0 6 | attribute :bw3p, default: 0.0 7 | end 8 | 9 | class TestIndicator < Quant::Indicators::Indicator 10 | include Quant::Mixins::ButterworthFilters 11 | 12 | def points_class 13 | TestPoint 14 | end 15 | 16 | def compute 17 | p0.bw2p = two_pole_butterworth(:input, period: 3, previous: :bw2p).round(3) 18 | p0.bw3p = three_pole_butterworth(:input, period: 3, previous: :bw3p).round(3) 19 | end 20 | end 21 | 22 | RSpec.describe Quant::Mixins::ButterworthFilters do 23 | let(:filename) { fixture_filename("DEUCES-sample.txt", :series) } 24 | let(:series) { Quant::Series.from_file(filename:, symbol: "DEUCES", interval: "1d") } 25 | 26 | subject { TestIndicator.new(series:, source: :oc2) } 27 | 28 | before { series.indicators.oc2.attach(indicator_class: TestIndicator, name: :bw) } 29 | 30 | context "deuces sample prices" do 31 | it { is_expected.to be_a(TestIndicator) } 32 | it { expect(subject.ticks.size).to eq(subject.series.size) } 33 | it { expect(subject.values.map(&:input)).to eq([3.0, 6.0, 12.0, 24.0]) } 34 | it { expect(subject.values.map(&:bw2p)).to eq([3.033, 4.516, 9.126, 18.335]) } 35 | it { expect(subject.values.map(&:bw3p)).to eq([3.227, 6.21, 12.509, 25.017]) } 36 | end 37 | 38 | context "growing price" do 39 | let(:series) { Quant::Series.new(symbol: "BW", interval: "1d") } 40 | 41 | [[1, 1.011, 1.076], 42 | [2, 1.505, 2.07], 43 | [3, 2.536, 3.094], 44 | [4, 3.564, 4.092], 45 | [5, 4.563, 5.092], 46 | [6, 5.562, 6.092]].each do |n, bw2p_expected, bw3p_expected| 47 | dataset = (1..n).to_a 48 | 49 | it "is #{bw2p_expected.inspect} for 2 pole when series: is #{dataset.inspect}" do 50 | dataset.each { |price| series << price } 51 | expect(subject.p0.bw2p).to eq bw2p_expected 52 | end 53 | 54 | it "is #{bw3p_expected.inspect} for 3 pole when series: is #{dataset.inspect}" do 55 | dataset.each { |price| series << price } 56 | expect(subject.p0.bw3p).to eq bw3p_expected 57 | end 58 | end 59 | end 60 | 61 | context "static price" do 62 | using Quant 63 | 64 | let(:series) { Quant::Series.new(symbol: "BW", interval: "1d") } 65 | 66 | before { 25.times { series << 5.0 } } 67 | 68 | it { expect(subject.ticks.size).to eq(subject.series.size) } 69 | it { expect(subject.values.map(&:input)).to be_all(5.0) } 70 | it { expect(subject.values.map(&:bw2p).uniq).to eq([5.055, 4.999, 4.997, 5.0]) } 71 | it { expect(subject.values.map(&:bw3p).uniq).to eq([5.378, 4.971, 4.993, 5.001, 5.0]) } 72 | end 73 | end 74 | end 75 | -------------------------------------------------------------------------------- /spec/lib/quant/mixins/super_smoother_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module SuperSmootherMixinTest 4 | class TestPoint < Quant::Indicators::IndicatorPoint 5 | attribute :ss3p, default: :oc2 6 | attribute :ss2p, default: :oc2 7 | end 8 | 9 | class TestIndicator < Quant::Indicators::Indicator 10 | include Quant::Mixins::SuperSmoother 11 | 12 | def points_class 13 | TestPoint 14 | end 15 | 16 | def compute 17 | p0.ss2p = two_pole_super_smooth(:input, period: 3, previous: :ss2p).round(3) 18 | p0.ss3p = three_pole_super_smooth(:input, period: 3, previous: :ss3p).round(3) 19 | end 20 | end 21 | 22 | RSpec.describe Quant::Mixins::SuperSmoother do 23 | let(:filename) { fixture_filename("DEUCES-sample.txt", :series) } 24 | let(:series) { Quant::Series.from_file(filename:, symbol: "DEUCES", interval: "1d") } 25 | 26 | describe "#super_smoother" do 27 | subject { TestIndicator.new(series:, source: :oc2) } 28 | 29 | before { series.indicators.oc2.attach(indicator_class: TestIndicator, name: :ss) } 30 | 31 | context "deuces sample prices" do 32 | it { is_expected.to be_a(TestIndicator) } 33 | it { expect(subject.ticks.size).to eq(subject.series.size) } 34 | it { expect(subject.values.map(&:input)).to eq([3.0, 6.0, 12.0, 24.0]) } 35 | it { expect(subject.values.map(&:ss2p)).to eq([3.0, 4.516, 9.065, 18.226]) } 36 | it { expect(subject.values.map(&:ss3p)).to eq([3.0, 6.399, 13.041, 25.984]) } 37 | end 38 | 39 | context "growing price" do 40 | let(:series) { Quant::Series.new(symbol: "SS", interval: "1d") } 41 | 42 | [[1, 1.0, 1.0], 43 | [2, 1.505, 2.133], 44 | [3, 2.516, 3.214], 45 | [4, 3.548, 4.182], 46 | [5, 4.574, 5.177], 47 | [6, 5.575, 6.181]].each do |n, expected_ss2p, expected_ss3p| 48 | dataset = (1..n).to_a 49 | 50 | it "is #{expected_ss2p.inspect} when series: is #{dataset.inspect}" do 51 | dataset.each { |price| series << price } 52 | expect(subject.p0.ss2p).to eq expected_ss2p 53 | end 54 | 55 | it "is #{expected_ss3p.inspect} when series: is #{dataset.inspect}" do 56 | dataset.each { |price| series << price } 57 | expect(subject.p0.ss3p).to eq expected_ss3p 58 | end 59 | end 60 | end 61 | 62 | context "static price" do 63 | let(:series) { Quant::Series.new(symbol: "SS", interval: "1d") } 64 | 65 | before { 25.times { series << 5.0 } } 66 | 67 | it { expect(subject.ticks.size).to eq(subject.series.size) } 68 | it { expect(subject.values.map(&:input)).to be_all(5.0) } 69 | it { expect(subject.values.map(&:ss2p)).to be_all(5.0) } 70 | it { expect(subject.values.map(&:ss3p)).to be_all(5.0) } 71 | end 72 | end 73 | end 74 | end 75 | -------------------------------------------------------------------------------- /lib/quant/indicators/mesa.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Quant 4 | module Indicators 5 | # The MESA inidicator 6 | class MesaPoint < IndicatorPoint 7 | attribute :mama, default: :input 8 | attribute :fama, default: :input 9 | attribute :dama, default: :input 10 | attribute :gama, default: :input 11 | attribute :lama, default: :input 12 | attribute :faga, default: :input 13 | attribute :osc, default: 0.0 14 | attribute :crossed, default: :unchanged 15 | 16 | def crossed_up? 17 | @crossed == :up 18 | end 19 | 20 | def crossed_down? 21 | @crossed == :down 22 | end 23 | end 24 | 25 | # https://www.mesasoftware.com/papers/MAMA.pdf 26 | # MESA Adaptive Moving Average (MAMA) adapts to price movement in an 27 | # entirely new and unique way. The adapation is based on the rate change 28 | # of phase as measured by the Hilbert Transform Discriminator. 29 | # 30 | # This version of Ehler's MAMA indicator ties into the homodyne 31 | # dominant cycle indicator to provide a more efficient computation 32 | # for this indicator. If you're using the homodyne in all your 33 | # indicators for the dominant cycle, then this version is useful 34 | # as it avoids extra computational steps. 35 | class Mesa < Indicator 36 | register name: :mesa 37 | depends_on DominantCycles::Homodyne 38 | 39 | def fast_limit 40 | @fast_limit ||= bars_to_alpha(micro_period) 41 | end 42 | 43 | def slow_limit 44 | @slow_limit ||= bars_to_alpha(max_period) 45 | end 46 | 47 | def homodyne_dominant_cycle 48 | series.indicators[source].dominant_cycles.homodyne 49 | end 50 | 51 | def current_dominant_cycle 52 | homodyne_dominant_cycle.points[t0] 53 | end 54 | 55 | def delta_phase 56 | current_dominant_cycle.delta_phase 57 | end 58 | 59 | FAMA = 0.500 60 | GAMA = 0.950 61 | DAMA = 0.125 62 | LAMA = 0.100 63 | FAGA = 0.050 64 | 65 | def compute 66 | alpha = [fast_limit / delta_phase, slow_limit].max 67 | 68 | p0.mama = (alpha * p0.input) + ((1.0 - alpha) * p1.mama) 69 | p0.fama = (FAMA * alpha * p0.mama) + ((1.0 - (FAMA * alpha)) * p1.fama) 70 | p0.gama = (GAMA * alpha * p0.mama) + ((1.0 - (GAMA * alpha)) * p1.gama) 71 | p0.dama = (DAMA * alpha * p0.mama) + ((1.0 - (DAMA * alpha)) * p1.dama) 72 | p0.lama = (LAMA * alpha * p0.mama) + ((1.0 - (LAMA * alpha)) * p1.lama) 73 | p0.faga = (FAGA * alpha * p0.fama) + ((1.0 - (FAGA * alpha)) * p1.faga) 74 | 75 | compute_oscillator 76 | end 77 | 78 | def compute_oscillator 79 | p0.osc = p0.mama - p0.fama 80 | p0.crossed = :up if p0.osc >= 0 && p1.osc < 0 81 | p0.crossed = :down if p0.osc <= 0 && p1.osc > 0 82 | end 83 | end 84 | end 85 | end 86 | -------------------------------------------------------------------------------- /spec/lib/quant/asset_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe Quant::Asset do 4 | context "from Alpaca Asset" do 5 | let(:asset) do 6 | { 7 | "id" => "21d40833-6d13-4031-9af3-02f6a516d791", 8 | "class" => "us_equity", 9 | "exchange" => "NYSE", 10 | "symbol" => "IBM", 11 | "name" => "International Business Machines Corporation", 12 | "status" => "active", 13 | "tradable" => true, 14 | "marginable" => true, 15 | "maintenance_margin_requirement" => 30, 16 | "shortable" => true, 17 | "easy_to_borrow" => true, 18 | "fractionable" => true, 19 | "attributes" => [] 20 | } 21 | end 22 | 23 | let(:asset_from_asset) do 24 | described_class.new( 25 | symbol: asset["symbol"], 26 | name: asset["name"], 27 | id: asset["id"], 28 | tradeable: asset["tradable"], 29 | active: asset["status"] == "active", 30 | exchange: asset["exchange"], 31 | asset_class: asset["class"], 32 | source: :alpaca, 33 | meta: asset 34 | ) 35 | end 36 | 37 | subject { asset_from_asset } 38 | 39 | it { is_expected.to be_a Quant::Asset } 40 | it { is_expected.to have_attributes(symbol: "IBM") } 41 | it { is_expected.to have_attributes(name: "International Business Machines Corporation") } 42 | it { is_expected.to be_active } 43 | it { is_expected.to be_tradeable } 44 | it { is_expected.to be_stock } 45 | it { expect(subject.symbol).to eq "IBM" } 46 | it { expect(subject.name).to eq "International Business Machines Corporation" } 47 | it { expect(subject.id).to eq "21d40833-6d13-4031-9af3-02f6a516d791" } 48 | it { expect(subject.exchange).to eq "NYSE" } 49 | it { expect(subject.asset_class).to eq :stock } 50 | it { expect(subject.source).to eq :alpaca } 51 | it { expect(subject.meta).to eq asset } 52 | it { expect(subject.created_at).to be_a Time } 53 | it { expect(subject.updated_at).to be_a Time } 54 | 55 | context "#to_h" do 56 | context "full: false" do 57 | it { expect(subject.to_h).to eq({ "s" => "IBM" }) } 58 | it { expect(subject.to_json).to eq("{\"s\":\"IBM\"}") } 59 | end 60 | 61 | context "full: true" do 62 | let(:expected_hash) do 63 | { "s" => "IBM", 64 | "n" => "International Business Machines Corporation", 65 | "id" => "21d40833-6d13-4031-9af3-02f6a516d791", 66 | "t" => true, 67 | "a" => true, 68 | "x" => "NYSE", 69 | "sc" => "stock", 70 | "src" => "alpaca" } 71 | end 72 | let(:expected_json) do 73 | Oj.dump(expected_hash) 74 | end 75 | 76 | it { expect(subject.to_h(full: true)).to eq expected_hash } 77 | it { expect(subject.to_json(full: true)).to eq expected_json } 78 | end 79 | end 80 | end 81 | end 82 | -------------------------------------------------------------------------------- /lib/quant/asset.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Quant 4 | # A {Quant::Asset} is a representation of a financial instrument such as a stock, option, future, or currency. 5 | # It is used to represent the instrument that is being traded, analyzed, or managed. 6 | # 7 | # Not all data sources have a rich set of attributes for their assets or securities. The {Quant::Asset} is designed 8 | # to be flexible and to support a wide variety of data sources and use-cases. The most common use-cases are supported 9 | # while allowing for additional attributes to be added via the +meta+ attribute, which is tyipically just a Hash, 10 | # but can be any object that can hold useful information about the asset such as currency formatting, precision, etc. 11 | # @example 12 | # asset = Quant::Asset.new(symbol: "AAPL", name: "Apple Inc.", asset_class: :stock, exchange: "NASDAQ") 13 | # asset.symbol # => "AAPL" 14 | # asset.name # => "Apple Inc." 15 | # asset.stock? # => true 16 | # asset.option? # => false 17 | # asset.future? # => false 18 | # asset.currency? # => false 19 | # asset.exchange # => "NASDAQ" 20 | # 21 | # # Can serialize two ways: 22 | # asset.to_h # => { "s" => "AAPL" } 23 | # asset.to_h(full: true) # => { "s" => "AAPL", "n" => "Apple Inc.", "sc" => "stock", "x" => "NASDAQ" } 24 | class Asset 25 | attr_reader :symbol, :name, :asset_class, :id, :exchange, :source, :meta, :created_at, :updated_at 26 | 27 | def initialize( 28 | symbol:, 29 | name: nil, 30 | id: nil, 31 | active: true, 32 | tradeable: true, 33 | exchange: nil, 34 | source: nil, 35 | asset_class: nil, 36 | created_at: Quant.current_time, 37 | updated_at: Quant.current_time, 38 | meta: {} 39 | ) 40 | raise ArgumentError, "symbol is required" unless symbol 41 | 42 | @symbol = symbol.to_s.upcase 43 | @name = name 44 | @id = id 45 | @tradeable = tradeable 46 | @active = active 47 | @exchange = exchange 48 | @source = source 49 | @asset_class = AssetClass.new(asset_class) 50 | @created_at = created_at 51 | @updated_at = updated_at 52 | @meta = meta 53 | end 54 | 55 | def active? 56 | !!@active 57 | end 58 | 59 | def tradeable? 60 | !!@tradeable 61 | end 62 | 63 | AssetClass::CLASSES.each do |class_name| 64 | define_method("#{class_name}?") do 65 | asset_class == class_name 66 | end 67 | end 68 | 69 | def to_h(full: false) 70 | return { "s" => symbol } unless full 71 | 72 | { "s" => symbol, 73 | "n" => name, 74 | "id" => id, 75 | "t" => tradeable?, 76 | "a" => active?, 77 | "x" => exchange, 78 | "sc" => asset_class.to_s, 79 | "src" => source.to_s } 80 | end 81 | 82 | def to_json(*args, full: false) 83 | Oj.dump(to_h(full:), *args) 84 | end 85 | end 86 | end 87 | -------------------------------------------------------------------------------- /spec/lib/quant/ticks/tick_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe Quant::Ticks::Tick do 4 | Quant::Ticks::Serializers::Tick.class_eval do 5 | def self.to_h(_tick) 6 | { "iv" => "1d", "ct" => 9999, "cp" => 5.0 } 7 | end 8 | end 9 | 10 | subject(:tick) { described_class.new } 11 | 12 | describe "#assign_series" do 13 | let(:indicators) { instance_double(Quant::IndicatorsSources) } 14 | let(:series) { instance_double(Quant::Series, interval: "1d", indicators:) } 15 | let(:another_series) { instance_double(Quant::Series) } 16 | 17 | before do 18 | allow(indicators).to receive(:<<) { |tick| tick } 19 | end 20 | 21 | it { expect(tick.series).to be_nil } 22 | 23 | it "assigns the series" do 24 | expect { tick.assign_series(series) }.to change(tick, :series).from(nil).to(series) 25 | end 26 | 27 | it "does not reassign another series" do 28 | expect { tick.assign_series(series) }.to change(tick, :series).from(nil).to(series) 29 | expect { tick.assign_series(another_series) }.not_to change(tick, :series).from(series) 30 | end 31 | end 32 | 33 | describe "#interval" do 34 | context "before a series is assigned" do 35 | it { expect(tick.interval).to eq Quant::Interval[nil] } 36 | it { expect(tick.interval).to be_nil } 37 | end 38 | 39 | context "when a series is assigned" do 40 | let(:series) { instance_double(Quant::Series, interval: "1d") } 41 | 42 | before { tick.assign_series(series) } 43 | 44 | it { expect(series.interval).to eq "1d" } 45 | it { expect(tick.interval).to eq "1d" } 46 | end 47 | end 48 | 49 | describe "#to_h" do 50 | let(:output) { { "iv" => "1d", "ct" => 9999, "cp" => 5.0 } } 51 | 52 | it "renders with default serializer" do 53 | expect(tick.to_h).to eq output 54 | end 55 | 56 | it "renders with given serializer" do 57 | serializer = Quant::Ticks::Serializers::Tick 58 | expect(tick.to_h(serializer_class: serializer)).to eq output 59 | end 60 | end 61 | 62 | describe "#to_json" do 63 | let(:output) { "{\"iv\":\"1d\",\"ct\":9999,\"cp\":5.0}" } 64 | 65 | it "renders with default serializer" do 66 | expect(tick.to_json).to eq output 67 | end 68 | 69 | it "renders with given serializer" do 70 | serializer = Quant::Ticks::Serializers::Tick 71 | expect(tick.to_json(serializer_class: serializer)).to eq output 72 | end 73 | end 74 | 75 | describe "#to_csv" do 76 | let(:output) { "1d,9999,5.0\n" } 77 | 78 | it "renders with default serializer" do 79 | expect(tick.to_csv).to eq output 80 | end 81 | 82 | it "renders with given serializer" do 83 | serializer = Quant::Ticks::Serializers::Tick 84 | expect(tick.to_csv(serializer_class: serializer)).to eq output 85 | end 86 | 87 | it "renders with header row" do 88 | expect(tick.to_csv(headers: true)).to eq "iv,ct,cp\n1d,9999,5.0\n" 89 | end 90 | end 91 | end 92 | -------------------------------------------------------------------------------- /lib/quant/ticks/spot.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Quant 4 | module Ticks 5 | # A +Spot+ is a single price point in time. It is the most basic form of a {Quant::Ticks::Tick} and is usually used to represent 6 | # a continuously streaming tick that just has a single price point at a given point in time. 7 | # @example 8 | # spot = Quant::Ticks::Spot.new(price: 100.0, timestamp: Time.now) 9 | # spot.price # => 100.0 10 | # spot.timestamp # => 2018-01-01 12:00:00 UTC 11 | # 12 | # @example 13 | # spot = Quant::Ticks::Spot.from({ "p" => 100.0, "t" => "2018-01-01 12:00:00 UTC", "bv" => 1000 }) 14 | # spot.price # => 100.0 15 | # spot.timestamp # => 2018-01-01 12:00:00 UTC 16 | # spot.volume # => 1000 17 | class Spot < Tick 18 | include TimeMethods 19 | 20 | attr_reader :series 21 | attr_reader :close_timestamp, :open_timestamp 22 | attr_reader :close_price 23 | attr_reader :base_volume, :target_volume, :trades 24 | 25 | def initialize( 26 | price: nil, 27 | timestamp: nil, 28 | close_price: nil, 29 | close_timestamp: nil, 30 | volume: nil, 31 | base_volume: nil, 32 | target_volume: nil, 33 | trades: nil 34 | ) 35 | raise ArgumentError, "Must supply a spot price as either :price or :close_price" unless price || close_price 36 | 37 | @close_price = (close_price || price).to_f 38 | 39 | @close_timestamp = extract_time(timestamp || close_timestamp || Quant.current_time) 40 | @open_timestamp = @close_timestamp 41 | 42 | @base_volume = (volume || base_volume).to_i 43 | @target_volume = (target_volume || @base_volume).to_i 44 | 45 | @trades = trades.to_i 46 | super() 47 | end 48 | 49 | alias timestamp close_timestamp 50 | alias price close_price 51 | alias high_price close_price 52 | alias low_price close_price 53 | alias open_price close_price 54 | alias oc2 close_price 55 | alias hl2 close_price 56 | alias hlc3 close_price 57 | alias ohlc4 close_price 58 | alias delta close_price 59 | alias volume base_volume 60 | 61 | # Two ticks are equal if they have the same close price and close timestamp. 62 | def ==(other) 63 | [close_price, close_timestamp] == [other.close_price, other.close_timestamp] 64 | end 65 | 66 | # The corresponding? method helps determine that the other tick's timestamp is the same as this tick's timestamp, 67 | # which is useful when aligning ticks between two separate series where one starts or ends at a different time, 68 | # or when there may be gaps in the data between the two series. 69 | def corresponding?(other) 70 | close_timestamp == other.close_timestamp 71 | end 72 | 73 | def inspect 74 | "#<#{self.class.name} ct=#{close_timestamp} c=#{close_price.to_f} v=#{volume}>" 75 | end 76 | end 77 | end 78 | end 79 | -------------------------------------------------------------------------------- /lib/quant/indicators/dominant_cycles/phase_accumulator.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Quant 4 | module Indicators 5 | module DominantCycles 6 | # The phase accumulation method of computing the dominant cycle is perhaps 7 | # the easiest to comprehend. In this technique, we measure the phase 8 | # at each sample by taking the arctangent of the ratio of the quadrature 9 | # component to the in-phase component. A delta phase is generated by 10 | # taking the difference of the phase between successive samples. 11 | # At each sample we can then look backwards, adding up the delta 12 | # phases. When the sum of the delta phases reaches 360 degrees, 13 | # we must have passed through one full cycle, on average. The process 14 | # is repeated for each new sample. 15 | # 16 | # The phase accumulation method of cycle measurement always uses one 17 | # full cycle’s worth of historical data. This is both an advantage 18 | # and a disadvantage. The advantage is the lag in obtaining the answer 19 | # scales directly with the cycle period. That is, the measurement of 20 | # a short cycle period has less lag than the measurement of a longer 21 | # cycle period. However, the number of samples used in making the 22 | # measurement means the averaging period is variable with cycle period. 23 | # Longer averaging reduces the noise level compared to the signal. 24 | # Therefore, shorter cycle periods necessarily have a higher output 25 | # signal-to-noise ratio. 26 | class PhaseAccumulator < DominantCycle 27 | register name: :phase_accumulator 28 | 29 | def compute_period 30 | p0.i1 = 0.15 * p0.i1 + 0.85 * p1.i1 31 | p0.q1 = 0.15 * p0.q1 + 0.85 * p1.q1 32 | 33 | p0.accumulator_phase = Math.atan(p0.q1 / p0.i1) unless p0.i1.zero? 34 | 35 | if p0.i1 < 0 && p0.q1 > 0 36 | p0.accumulator_phase = 180.0 - p0.accumulator_phase 37 | elsif p0.i1 < 0 && p0.q1 < 0 38 | p0.accumulator_phase = 180.0 + p0.accumulator_phase 39 | elsif p0.i1 > 0 && p0.q1 < 0 40 | p0.accumulator_phase = 360.0 - p0.accumulator_phase 41 | end 42 | 43 | p0.delta_phase = p1.accumulator_phase - p0.accumulator_phase 44 | if p1.accumulator_phase < 90.0 && p0.accumulator_phase > 270.0 45 | p0.delta_phase = 360.0 + p1.accumulator_phase - p0.accumulator_phase 46 | end 47 | 48 | p0.delta_phase = p0.delta_phase.clamp(min_period, max_period) 49 | 50 | p0.inst_period = p1.inst_period 51 | period_points(max_period).each_with_index do |prev, index| 52 | p0.phase_sum += prev.delta_phase 53 | if p0.phase_sum > 360.0 54 | p0.inst_period = index 55 | break 56 | end 57 | end 58 | p0.period = (0.25 * p0.inst_period + 0.75 * p1.inst_period).round(0) 59 | end 60 | end 61 | end 62 | end 63 | end 64 | -------------------------------------------------------------------------------- /lib/quant/ticks/serializers/ohlc.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Quant 4 | module Ticks 5 | module Serializers 6 | class OHLC < Tick 7 | # Returns a +Quant::Ticks::Tick+ from a valid JSON +String+. 8 | # @param json [String] 9 | # @param tick_class [Quant::Ticks::Tick] 10 | # @return [Quant::Ticks::Tick] 11 | # @example 12 | # json = 13 | # Quant::Ticks::Serializers::Tick.from_json(json, tick_class: Quant::Ticks::Spot) 14 | def self.from_json(json, tick_class:) 15 | hash = Oj.load(json) 16 | from(hash, tick_class:) 17 | end 18 | 19 | # Instantiates a tick from a +Hash+. The hash keys are expected to be the same as the serialized keys. 20 | # 21 | # Serialized Keys: 22 | # - ot: open timestamp 23 | # - ct: close timestamp 24 | # - o: open price 25 | # - h: high price 26 | # - l: low price 27 | # - c: close price 28 | # - bv: base volume 29 | # - tv: target volume 30 | # - t: trades 31 | # - g: green 32 | # - j: doji 33 | def self.from(hash, tick_class:) 34 | tick_class.new \ 35 | open_timestamp: hash["ot"], 36 | close_timestamp: hash["ct"], 37 | 38 | open_price: hash["o"], 39 | high_price: hash["h"], 40 | low_price: hash["l"], 41 | close_price: hash["c"], 42 | 43 | base_volume: hash["bv"], 44 | target_volume: hash["tv"], 45 | 46 | trades: hash["t"], 47 | green: hash["g"], 48 | doji: hash["j"] 49 | end 50 | 51 | # Returns a +Hash+ of the Spot tick's key properties 52 | # 53 | # Serialized Keys: 54 | # 55 | # - ot: open timestamp 56 | # - ct: close timestamp 57 | # - o: open price 58 | # - h: high price 59 | # - l: low price 60 | # - c: close price 61 | # - bv: base volume 62 | # - tv: target volume 63 | # - t: trades 64 | # - g: green 65 | # - j: doji 66 | # 67 | # @param tick [Quant::Ticks::Tick] 68 | # @return [Hash] 69 | # @example 70 | # Quant::Ticks::Serializers::Tick.to_h(tick) 71 | # # => { "ot" => [Time], "ct" => [Time], "o" => 1.0, "h" => 2.0, 72 | # # "l" => 0.5, "c" => 1.5, "bv" => 6.0, "tv" => 5.0, "t" => 1, "g" => true, "j" => true } 73 | def self.to_h(tick) 74 | { "ot" => tick.open_timestamp, 75 | "ct" => tick.close_timestamp, 76 | 77 | "o" => tick.open_price, 78 | "h" => tick.high_price, 79 | "l" => tick.low_price, 80 | "c" => tick.close_price, 81 | 82 | "bv" => tick.base_volume, 83 | "tv" => tick.target_volume, 84 | 85 | "t" => tick.trades, 86 | "g" => tick.green, 87 | "j" => tick.doji } 88 | end 89 | end 90 | end 91 | end 92 | end 93 | -------------------------------------------------------------------------------- /lib/quant/indicators/ema.rb: -------------------------------------------------------------------------------- 1 | module Quant 2 | module Indicators 3 | class EmaPoint < IndicatorPoint 4 | attribute :ss_dc_period, default: :input 5 | attribute :ss_half_dc_period, default: :input 6 | attribute :ss_micro_period, default: :input 7 | attribute :ss_min_period, default: :input 8 | attribute :ss_half_period, default: :input 9 | attribute :ss_max_period, default: :input 10 | 11 | attribute :ema_dc_period, default: :input 12 | attribute :ema_half_dc_period, default: :input 13 | attribute :ema_micro_period, default: :input 14 | attribute :ema_min_period, default: :input 15 | attribute :ema_half_period, default: :input 16 | attribute :ema_max_period, default: :input 17 | 18 | attribute :osc_dc_period, default: 0.0 19 | attribute :osc_half_dc_period, default: 0.0 20 | attribute :osc_micro_period, default: 0.0 21 | attribute :osc_min_period, default: 0.0 22 | attribute :osc_half_period, default: 0.0 23 | attribute :osc_max_period, default: 0.0 24 | end 25 | 26 | class Ema < Indicator 27 | register name: :ema 28 | 29 | def half_dc_period 30 | dc_period / 2 31 | end 32 | 33 | def compute_super_smoothers 34 | p0.ss_dc_period = super_smoother :input, previous: :ss_dc_period, period: dc_period 35 | p0.ss_half_dc_period = super_smoother :input, previous: :ss_half_dc_period, period: half_dc_period 36 | p0.ss_micro_period = super_smoother :input, previous: :ss_micro_period, period: micro_period 37 | p0.ss_min_period = super_smoother :input, previous: :ss_min_period, period: min_period 38 | p0.ss_half_period = super_smoother :input, previous: :ss_half_period, period: half_period 39 | p0.ss_max_period = super_smoother :input, previous: :ss_max_period, period: max_period 40 | end 41 | 42 | def compute_emas 43 | p0.ema_dc_period = ema :input, previous: :ema_dc_period, period: dc_period 44 | p0.ema_half_dc_period = ema :input, previous: :ema_half_dc_period, period: half_dc_period 45 | p0.ema_micro_period = ema :input, previous: :ema_micro_period, period: micro_period 46 | p0.ema_min_period = ema :input, previous: :ema_min_period, period: min_period 47 | p0.ema_half_period = ema :input, previous: :ema_half_period, period: half_period 48 | p0.ema_max_period = ema :input, previous: :ema_max_period, period: max_period 49 | end 50 | 51 | def compute_oscillators 52 | p0.osc_dc_period = p0.ss_dc_period - p0.ema_dc_period 53 | p0.osc_half_dc_period = p0.ss_half_dc_period - p0.ema_half_dc_period 54 | p0.osc_micro_period = p0.ss_micro_period - p0.ema_micro_period 55 | p0.osc_min_period = p0.ss_min_period - p0.ema_min_period 56 | p0.osc_half_period = p0.ss_half_period - p0.ema_half_period 57 | p0.osc_max_period = p0.ss_max_period - p0.ema_max_period 58 | end 59 | 60 | def compute 61 | compute_super_smoothers 62 | compute_emas 63 | compute_oscillators 64 | end 65 | end 66 | end 67 | end -------------------------------------------------------------------------------- /spec/lib/quant/ticks/serializers/ohlc_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe Quant::Ticks::Serializers::OHLC do 4 | let(:current_time) { Quant.current_time.round } 5 | let(:one_minute) { 60 } 6 | let(:open_time) { current_time } 7 | let(:close_time) { current_time + one_minute } 8 | let(:tick_class) { Quant::Ticks::OHLC } 9 | 10 | describe ".from" do 11 | let(:hash) do 12 | { 13 | "ot" => open_time.to_i, # deserializes with Time.at 14 | "ct" => close_time.iso8601, # deserializes with Time.parse 15 | "o" => 1.0, 16 | "h" => 2.0, 17 | "l" => 3.0, 18 | "c" => 4.0, 19 | "bv" => 2.0, 20 | "tv" => 3.0 21 | } 22 | end 23 | 24 | subject(:tick) { described_class.from(hash, tick_class:) } 25 | 26 | context "valid" do 27 | it { is_expected.to be_a(tick_class) } 28 | 29 | it "has the correct attributes" do 30 | expect(tick.close_timestamp).to eq(close_time) 31 | expect(tick.open_timestamp).to eq(open_time) 32 | 33 | expect(tick.open_price).to eq(1.0) 34 | expect(tick.high_price).to eq(2.0) 35 | expect(tick.low_price).to eq(3.0) 36 | expect(tick.close_price).to eq(4.0) 37 | 38 | expect(tick.base_volume).to eq(2.0) 39 | expect(tick.target_volume).to eq(3.0) 40 | end 41 | 42 | describe "#to_h" do 43 | subject { tick.to_h } 44 | 45 | it { expect(subject["ot"]).to eq(open_time) } 46 | it { expect(subject["ct"]).to eq(close_time) } 47 | it { expect(subject["o"]).to eq(1.0) } 48 | it { expect(subject["h"]).to eq(2.0) } 49 | it { expect(subject["l"]).to eq(3.0) } 50 | it { expect(subject["c"]).to eq(4.0) } 51 | it { expect(subject["bv"]).to eq(2.0) } 52 | it { expect(subject["tv"]).to eq(3.0) } 53 | end 54 | end 55 | 56 | context "without volume" do 57 | let(:hash) do 58 | { 59 | "ot" => open_time, 60 | "ct" => current_time.to_i, 61 | "o" => 1.0, 62 | "c" => 1.0, 63 | "l" => 1.0, 64 | "h" => 1.0 65 | } 66 | end 67 | 68 | it "has the correct attributes" do 69 | expect(tick.close_timestamp).to eq(current_time) 70 | expect(tick.close_price).to eq(1.0) 71 | expect(tick.base_volume).to eq(0.0) 72 | expect(tick.target_volume).to eq(0.0) 73 | end 74 | end 75 | end 76 | 77 | describe ".from_json" do 78 | let(:json) { Oj.dump({ "ot" => open_time, "ct" => close_time, "c" => 1.0, "bv" => 2.0, "tv" => 3.0 }) } 79 | 80 | subject(:tick) { described_class.from_json(json, tick_class:) } 81 | 82 | context "valid" do 83 | it { is_expected.to be_a(tick_class) } 84 | 85 | it "has the correct attributes" do 86 | expect(tick.open_timestamp).to eq(open_time) 87 | expect(tick.close_timestamp).to eq(close_time) 88 | expect(tick.close_price).to eq(1.0) 89 | expect(tick.base_volume).to eq(2.0) 90 | expect(tick.target_volume).to eq(3.0) 91 | end 92 | end 93 | end 94 | end 95 | -------------------------------------------------------------------------------- /lib/quant/indicators/adx.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Quant 4 | module Indicators 5 | class AdxPoint < IndicatorPoint 6 | attribute :dmu, default: 0.0 7 | attribute :dmd, default: 0.0 8 | attribute :dmu_ema, default: 0.0 9 | attribute :dmd_ema, default: 0.0 10 | attribute :diu, default: 0.0 11 | attribute :did, default: 0.0 12 | attribute :di, default: 0.0 13 | attribute :di_ema, default: 0.0 14 | attribute :value, default: 0.0 15 | attribute :inst_stoch, default: 0.0 16 | attribute :stoch, default: 0.0 17 | attribute :stoch_up, default: false 18 | attribute :stoch_turned, default: false 19 | attribute :ssf, default: 0.0 20 | attribute :hp, default: 0.0 21 | end 22 | 23 | class Adx < Indicator 24 | register name: :adx 25 | depends_on Indicators::Atr 26 | 27 | def scale 28 | 1.0 29 | end 30 | 31 | def period 32 | dc_period 33 | end 34 | 35 | def atr_point 36 | series.indicators[source].atr.points[t0] 37 | end 38 | 39 | def compute 40 | # To calculate the ADX, first determine the + and - directional movement, or DM. 41 | # The +DM and -DM are found by calculating the "up-move," or current high minus 42 | # the previous high, and "down-move," or current low minus the previous low. 43 | # If the up-move is greater than the down-move and greater than zero, the +DM equals the up-move; 44 | # otherwise, it equals zero. If the down-move is greater than the up-move and greater than zero, 45 | # the -DM equals the down-move; otherwise, it equals zero. 46 | dm_highs = [t0.high_price - t1.high_price, 0.0].max 47 | dm_lows = [t0.low_price - t1.low_price, 0.0].max 48 | 49 | p0.dmu = dm_highs > dm_lows ? 0.0 : dm_highs 50 | p0.dmd = dm_lows > dm_highs ? 0.0 : dm_lows 51 | 52 | p0.dmu_ema = three_pole_super_smooth :dmu, period:, previous: :dmu_ema 53 | p0.dmd_ema = three_pole_super_smooth :dmd, period:, previous: :dmd_ema 54 | 55 | atr_value = atr_point.fast * scale 56 | return if atr_value == 0.0 || @points.size < period 57 | 58 | # The positive directional indicator, or +DI, equals 100 times the EMA of +DM divided by the ATR 59 | # over a given number of time periods. Welles usually used 14 periods. 60 | # The negative directional indicator, or -DI, equals 100 times the EMA of -DM divided by the ATR. 61 | p0.diu = (100.0 * p0.dmu_ema) / atr_value 62 | p0.did = (100.0 * p0.dmd_ema) / atr_value 63 | 64 | # The ADX indicator itself equals 100 times the EMA of the absolute value of (+DI minus -DI) 65 | # divided by (+DI plus -DI). 66 | delta = p0.diu + p0.did 67 | p0.di = (p0.diu - p1.did).abs / delta 68 | p0.di_ema = three_pole_super_smooth(:di, period:, previous: :di_ema).clamp(-10.0, 10.0) 69 | 70 | p0.value = p0.di_ema 71 | p0.inst_stoch = stochastic(:di, period:) 72 | p0.stoch = three_pole_super_smooth :inst_stoch, period:, previous: :stoch 73 | end 74 | end 75 | end 76 | end 77 | -------------------------------------------------------------------------------- /lib/quant/indicators/decycler.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Quant 4 | module Indicators 5 | # The decycler oscillator can be useful for determining the transition be- tween uptrends and downtrends by the crossing of the zero 6 | # line. Alternatively, the changes of slope of the decycler oscillator are easier to identify than the changes in slope of the 7 | # original decycler. Optimum cutoff periods can easily be found by experimentation. 8 | # 9 | # 1. A decycler filter functions the same as a low-pass filter. 10 | # 2. A decycler filter is created by subtracting the output of a high-pass filter from the input, thereby removing the 11 | # high-frequency components by cancellation. 12 | # 3. A decycler filter has very low lag. 13 | # 4. A decycler oscillator is created by subtracting the output of a high-pass filter having a shorter cutoff period from the 14 | # output of another high-pass filter having a longer cutoff period. 15 | # 5. A decycler oscillator shows transitions between uptrends and down-trends at the zero crossings. 16 | class DecyclerPoint < IndicatorPoint 17 | attribute :decycle, default: :input 18 | attribute :hp1, default: 0.0 19 | attribute :hp2, default: 0.0 20 | attribute :osc, default: 0.0 21 | attribute :peak, default: 0.0 22 | attribute :agc, default: 0.0 23 | attribute :ift, default: 0.0 24 | end 25 | 26 | class Decycler < Indicator 27 | register name: :decycler 28 | 29 | def max_period 30 | dc_period 31 | end 32 | 33 | def compute_decycler 34 | alpha = period_to_alpha(max_period) 35 | p0.decycle = (alpha / 2) * (p0.input + p1.input) + (1.0 - alpha) * p1.decycle 36 | end 37 | 38 | # alpha1 = (Cosine(.707*360 / HPPeriod1) + Sine (.707*360 / HPPeriod1) - 1) / Cosine(.707*360 / HPPeriod1); 39 | # HP1 = (1 - alpha1 / 2)*(1 - alpha1 / 2)*(Close - 2*Close[1] + Close[2]) + 2*(1 - alpha1)*HP1[1] - (1 - alpha1)*(1 - alpha1)*HP1[2]; 40 | def compute_hp(period, hp) 41 | radians = deg2rad(360) 42 | c = Math.cos(0.707 * radians / period) 43 | s = Math.sin(0.707 * radians / period) 44 | alpha = (c + s - 1) / c 45 | (1 - alpha / 2)**2 * (p0.input - 2 * p1.input + p2.input) + 2 * (1 - alpha) * p1.send(hp) - (1 - alpha) * (1 - alpha) * p2.send(hp) 46 | end 47 | 48 | def compute_oscillator 49 | p0.hp1 = compute_hp(min_period, :hp1) 50 | p0.hp2 = compute_hp(max_period, :hp2) 51 | p0.osc = p0.hp2 - p0.hp1 52 | end 53 | 54 | # AGC is constrained to -1.0 to 1.0 55 | # The peak decays at a rate of 0.991 per bar 56 | def compute_automatic_gain_control 57 | p0.peak = [p0.osc.abs, 0.991 * p1.peak].max 58 | p0.agc = p0.peak.zero? ? p0.osc : p0.osc / p0.peak 59 | end 60 | 61 | def compute_inverse_fisher_transform 62 | p0.ift = inverse_fisher_transform(p0.agc, scale_factor: 5.0) 63 | end 64 | 65 | def compute 66 | compute_decycler 67 | compute_oscillator 68 | compute_automatic_gain_control 69 | compute_inverse_fisher_transform 70 | end 71 | end 72 | end 73 | end 74 | -------------------------------------------------------------------------------- /lib/quant/indicators/pivots/pivot.rb: -------------------------------------------------------------------------------- 1 | module Quant 2 | module Indicators 3 | module Pivots 4 | class PivotPoint < IndicatorPoint 5 | attribute :avg_high, default: :high_price 6 | attribute :highest, default: :input 7 | 8 | attribute :avg_low, default: :low_price 9 | attribute :lowest, default: :input 10 | 11 | attribute :range, default: 0.0 12 | attribute :avg_range, default: 0.0 13 | attribute :std_dev, default: 0.0 14 | 15 | def bands 16 | @bands ||= { 0 => input } 17 | end 18 | 19 | def [](band) 20 | bands[band] 21 | end 22 | 23 | def []=(band, value) 24 | bands[band] = value 25 | end 26 | 27 | def key?(band) 28 | bands.key?(band) 29 | end 30 | 31 | def midpoint 32 | bands[0] 33 | end 34 | alias :h0 :midpoint 35 | alias :l0 :midpoint 36 | 37 | def midpoint=(value) 38 | bands[0] = value 39 | end 40 | alias :h0= :midpoint= 41 | alias :l0= :midpoint= 42 | 43 | (1..8).each do |band| 44 | define_method("h#{band}") { bands[band] } 45 | define_method("h#{band}=") { |value| bands[band] = value } 46 | 47 | define_method("l#{band}") { bands[-band] } 48 | define_method("l#{band}=") { |value| bands[-band] = value } 49 | end 50 | end 51 | 52 | class Pivot < Indicator 53 | def points_class 54 | Quant::Indicators::Pivots::PivotPoint 55 | end 56 | 57 | def band?(band) 58 | p0.key?(band) 59 | end 60 | 61 | def period 62 | adaptive_period 63 | end 64 | 65 | def averaging_period 66 | min_period 67 | end 68 | 69 | def period_midpoints 70 | period_points(period).map(&:midpoint) 71 | end 72 | 73 | def smoothed_average_midpoint 74 | three_pole_super_smooth :input, previous: :midpoint, period: averaging_period 75 | end 76 | 77 | def compute 78 | compute_extents 79 | compute_value 80 | compute_midpoint 81 | compute_bands 82 | end 83 | 84 | def compute_midpoint 85 | p0.midpoint = p0.input 86 | end 87 | 88 | def compute_value 89 | # No-op -- override in subclasses 90 | end 91 | 92 | def compute_bands 93 | # No-op -- override in subclasses 94 | end 95 | 96 | def compute_extents 97 | period_midpoints.tap do |midpoints| 98 | p0.highest = midpoints.max 99 | p0.lowest = midpoints.min 100 | p0.range = p0.high_price - p0.low_price 101 | p0.avg_low = three_pole_super_smooth(:low_price, previous: :avg_low, period: averaging_period) 102 | p0.avg_high = three_pole_super_smooth(:high_price, previous: :avg_high, period: averaging_period) 103 | p0.avg_range = three_pole_super_smooth(:range, previous: :avg_range, period: averaging_period) 104 | end 105 | end 106 | end 107 | end 108 | end 109 | end --------------------------------------------------------------------------------