sqa 0.0.6 → 0.0.8

Sign up to get free protection for your applications and to get access to all the features.
data/lib/sqa/cli.rb CHANGED
@@ -1,310 +1,173 @@
1
1
  # lib/sqa/cli.rb
2
2
 
3
- require_relative '../sqa'
3
+ require 'tty-option'
4
4
 
5
- module SQA
6
- class CLI
7
- def initialize
8
- @args = $ARGV.dup
9
- end
5
+ require_relative '../sqa'
10
6
 
11
- def run
12
- stock = Stock.new('aapl')
7
+ # SMELL: Architectyre has become confused between CLI and Command
13
8
 
14
- puts <<~OUTPUT
9
+ # TODO: Fix the mess between CLI and Command
15
10
 
16
- TBD
17
- @args => #{@args}
18
- stock => #{stock}
19
11
 
20
- OUTPUT
21
- end
12
+ module SQA
13
+ class CLI
14
+ include TTY::Option
15
+
16
+ header "Stock Quantitative Analysis (SQA)"
17
+ footer "WARNING: This is a toy, a play thing, not intended for serious use."
18
+
19
+ program "sqa"
20
+ desc "A collection of things"
21
+
22
+ example "sqa -c ~/.sqa.yml -p portfolio.csv -t trades.csv --data-dir ~/sqa_data"
23
+
24
+
25
+ option :config_file do
26
+ short "-c string"
27
+ long "--config string"
28
+ desc "Path to the config file"
29
+ end
30
+
31
+ option :log_level do
32
+ short "-l string"
33
+ long "--log_level string"
34
+ # default SQA.config.log_level
35
+ desc "Set the log level (debug, info, warn, error, fatal)"
36
+ end
37
+
38
+ option :portfolio do
39
+ short "-p string"
40
+ long "--portfolio string"
41
+ # default SQA.config.portfolio_filename
42
+ desc "Set the filename of the portfolio"
43
+ end
44
+
45
+
46
+ option :trades do
47
+ short "-t string"
48
+ long "--trades string"
49
+ # default SQA.config.trades_filename
50
+ desc "Set the filename into which trades are stored"
51
+ end
52
+
53
+
54
+ option :data_dir do
55
+ long "--data-dir string"
56
+ # default SQA.config.data_dir
57
+ desc "Set the directory for the SQA data"
58
+ end
59
+
60
+ flag :dump_config do
61
+ long "--dump-config path_to_file"
62
+ desc "Dump the current configuration"
63
+ end
64
+
65
+ flag :help do
66
+ short "-h"
67
+ long "--help"
68
+ desc "Print usage"
69
+ end
70
+
71
+ flag :version do
72
+ long "--version"
73
+ desc "Print version"
74
+ end
75
+
76
+ flag :debug do
77
+ short "-d"
78
+ long "--debug"
79
+ # default SQA.config.debug
80
+ desc "Turn on debugging output"
81
+ end
82
+
83
+ flag :verbose do
84
+ short "-v"
85
+ long "--verbose"
86
+ # default SQA.config.debug
87
+ desc "Print verbosely"
88
+ end
89
+
90
+ class << self
91
+ @@subclasses = []
92
+ @@commands_available = []
93
+
94
+ def names
95
+ '['+ @@commands_available.join('|')+']'
96
+ end
97
+
98
+ def inherited(subclass)
99
+ super
100
+ @@subclasses << subclass
101
+ @@commands_available << subclass.command.join
102
+ end
103
+
104
+ def command_descriptions
105
+ help_block = "Optional Command Available:"
106
+
107
+ @@commands_available.size.times do |x|
108
+ klass = @@subclasses[x]
109
+ help_block << "\n " + @@commands_available[x] + " - "
110
+ help_block << klass.desc.join
111
+ end
112
+
113
+ help_block
114
+ end
115
+
116
+
117
+ ##################################################
118
+ def run(argv = ARGV)
119
+ cli = new
120
+ parser = cli.parse(argv)
121
+ params = parser.params
122
+
123
+ if params[:help]
124
+ print parser.help
125
+ exit(0)
126
+
127
+ elsif params.errors.any?
128
+ puts params.errors.summary
129
+ exit(1)
130
+
131
+ elsif params[:version]
132
+ puts SQA.version
133
+ exit(0)
134
+
135
+ elsif params[:dump_config]
136
+ SQA.config.config_file = params[:dump_config]
137
+ `touch #{SQA.config.config_file}`
138
+ SQA.config.dump_file
139
+ exit(0)
140
+
141
+ elsif params[:config_file]
142
+ # Override the defaults <- envars <- config file content
143
+ SQA.config.config_file = params[:config_file]
144
+ SQA.config.from_file
145
+ end
146
+
147
+ # Override the defaults <- envars <- config file <- cli parameters
148
+ SQA.config.merge!(remove_temps params.to_h)
149
+
150
+ if SQA.debug? || SQA.verbose?
151
+ debug_me("config after CLI parameters"){[
152
+ "SQA.config"
153
+ ]}
154
+ end
155
+ end
156
+
157
+ def remove_temps(a_hash)
158
+ temps = %i[ help version dump ]
159
+ debug_me{[ :a_hash ]}
160
+ a_hash.reject{|k, _| temps.include? k}
161
+ end
162
+ end
22
163
  end
23
164
  end
24
165
 
25
- __END__
26
-
27
- ###################################################
28
- ## This is the old thing that got me started ...
29
-
30
- #!/usr/bin/env ruby
31
- # experiments/stocks/analysis.rb
32
- #
33
- # Some technical indicators from FinTech gem
34
- #
35
- # optional date CLI option in format YYYY-mm-dd
36
- # if not present uses Date.today
37
- #
38
-
39
-
40
- INVEST = 1000.00
41
-
42
- require 'pathname'
43
-
44
- require_relative 'stock'
45
- require_relative 'datastore'
46
-
47
-
48
- STOCKS = Pathname.pwd + "stocks.txt"
49
- TRADES = Pathname.pwd + "trades.csv"
50
-
51
- TRADES_FILE = File.open(TRADES, 'a')
52
-
53
- unless STOCKS.exist?
54
- puts
55
- puts "ERROR: The 'stocks.txt' file does not exist."
56
- puts
57
- exot(-1)
58
- end
59
-
60
- require 'debug_me'
61
- include DebugMe
62
-
63
- require 'csv'
64
- require 'date'
65
- require 'tty-table'
66
-
67
- require 'fin_tech'
68
- require 'previous_dow'
69
-
70
- class NilClass
71
- def blank?() = true
72
- end
73
-
74
- class String
75
- def blank?() = strip().empty?
76
- end
77
-
78
- class Array
79
- def blank?() = empty?
80
- end
81
-
82
-
83
- def tickers
84
- return @tickers unless @tickers.blank?
85
-
86
- @tickers = []
87
-
88
- STOCKS.readlines.each do |a_line|
89
- ticker_symbol = a_line.chomp.strip.split()&.first&.downcase
90
- next if ticker_symbol.blank? || '#' == ticker_symbol
91
- @tickers << ticker_symbol unless @tickers.include?(ticker_symbol)
92
- end
93
-
94
- @tickers.sort!
95
- end
96
-
97
- given_date = ARGV.first ? Date.parse(ARGV.first) : Date.today
98
-
99
- start_date = Date.new(2019, 1, 1)
100
- end_date = previous_dow(:friday, given_date)
101
-
102
- ASOF = end_date.to_s.tr('-','')
103
-
104
-
105
- #######################################################################
106
- # download a CSV file from https://query1.finance.yahoo.com
107
- # given a stock ticker symbol as a String
108
- # start and end dates
109
- #
110
- # For ticker "aapl" the downloaded file will be named "aapl.csv"
111
- # That filename will be renamed to "aapl_YYYYmmdd.csv" where the
112
- # date suffix is the end_date of the historical data.
113
- #
114
- def download_historical_prices(ticker, start_date, end_date)
115
- data_path = Pathname.pwd + "#{ticker}_#{ASOF}.csv"
116
- return if data_path.exist?
117
-
118
- mew_path = Pathname.pwd + "#{ticker}.csv"
119
-
120
- start_timestamp = start_date.to_time.to_i
121
- end_timestamp = end_date.to_time.to_i
122
- ticker_upcase = ticker.upcase
123
- filename = "#{ticker.downcase}.csv"
124
-
125
- `curl -o #{filename} "https://query1.finance.yahoo.com/v7/finance/download/#{ticker_upcase}?period1=#{start_timestamp}&period2=#{end_timestamp}&interval=1d&events=history&includeAdjustedClose=true"`
126
-
127
- mew_path.rename data_path
128
- end
129
-
130
-
131
- #######################################################################
132
- # Read the CSV file associated with the give ticker symbol
133
- # and the ASOF date.
134
- #
135
- def read_csv(ticker)
136
- filename = "#{ticker.downcase}_#{ASOF}.csv"
137
- data = []
138
-
139
- CSV.foreach(filename, headers: true) do |row|
140
- data << row.to_h
141
- end
142
-
143
- data
144
- end
145
-
146
- ##########################
147
- # record a recommend trade
148
-
149
- def trade(ticker, signal, shares, price)
150
- TRADES_FILE.puts "#{ticker},#{ASOF},#{signal},#{shares},#{price}"
151
- end
152
-
153
- #######################################################################
154
- ###
155
- ## Main
156
- #
157
-
158
-
159
- tickers.each do |ticker|
160
- download_historical_prices(ticker, start_date, end_date)
161
- end
162
-
163
- result = {}
164
-
165
- mwfd = 14 # moving_window_forcast_days
166
-
167
- headers = %w[ Ticker AdjClose Trend Slope M'tum RSI Analysis MACD Target Signal $]
168
- values = []
169
-
170
- tickers.each do |ticker|
171
-
172
- data = read_csv ticker
173
- prices = data.map{|r| r["Adj Close"].to_f}
174
- volumes = data.map{|r| r["volume"].to_f}
175
-
176
- if data.blank?
177
- puts
178
- puts "ERROR: cannot get data for #{ticker}"
179
- puts
180
- next
181
- end
182
-
183
-
184
- result[ticker] = {
185
- date: data.last["Date"],
186
- adj_close: data.last["Adj Close"].to_f
187
- }
188
-
189
- result[ticker][:market] = FinTech.classify_market_profile(
190
- volumes.last(mwfd),
191
- prices.last(mwfd),
192
- prices.last(mwfd).first,
193
- prices.last
194
- )
195
-
196
- fr = FinTech.fibonacci_retracement( prices.last(mwfd).first,
197
- prices.last).map{|x| x.round(3)}
198
-
199
-
200
- puts "\n#{result[ticker][:market]} .. #{ticker}\t#{fr}"
201
- print "\t"
202
- print FinTech.head_and_shoulders_pattern?(prices.last(mwfd))
203
- print "\t"
204
- print FinTech.double_top_bottom_pattern?(prices.last(mwfd))
205
- print "\t"
206
- mr = FinTech.mean_reversion?(prices, mwfd, 0.5)
207
- print mr
208
-
209
- if mr
210
- print "\t"
211
- print FinTech.mr_mean(prices, mwfd).round(3)
212
- end
213
-
214
- print "\t"
215
- print FinTech.identify_wave_condition?(prices, 2*mwfd, 1.0)
216
- puts
217
-
218
- print "\t"
219
- print FinTech.ema_analysis(prices, mwfd).except(:ema_values)
220
-
221
- puts
222
-
223
- row = [ ticker ]
224
-
225
- # result[ticker][:moving_averages] = FinTech.sma(data, mwfd)
226
- result[ticker][:trend] = FinTech.sma_trend(data, mwfd)
227
- result[ticker][:momentum] = FinTech.momentum(prices, mwfd)
228
- result[ticker][:rsi] = FinTech.rsi(data, mwfd)
229
- result[ticker][:bollinger_bands] = FinTech.bollinger_bands(data, mwfd, 2)
230
- result[ticker][:macd] = FinTech.macd(data, mwfd, 2*mwfd, mwfd/2)
231
-
232
- price = result[ticker][:adj_close].round(3)
233
-
234
- row << price
235
- row << result[ticker][:trend][:trend]
236
- row << result[ticker][:trend][:angle].round(3)
237
- row << result[ticker][:momentum].round(3)
238
- row << result[ticker][:rsi][:rsi].round(3)
239
- row << result[ticker][:rsi][:meaning]
240
- row << result[ticker][:macd].first.round(3)
241
- row << result[ticker][:macd].last.round(3)
242
-
243
- analysis = result[ticker][:rsi][:meaning]
244
-
245
- signal = ""
246
- macd_diff = result[ticker][:macd].first
247
- target = result[ticker][:macd].last
248
- current = result[ticker][:adj_close]
249
-
250
- trend_down = "down" == result[ticker][:trend][:trend]
251
-
252
- if current < target
253
- signal = "buy" unless "Over Bought" == analysis
254
- elsif (current > target) && trend_down
255
- signal = "sell" unless "Over Sold" == analysis
256
- end
257
-
258
- if "buy" == signal
259
- pps = target - price
260
- shares = INVEST.to_i / price.to_i
261
- upside = (shares * pps).round(2)
262
- trade(ticker, signal, shares, price)
263
- elsif "sell" == signal
264
- pps = target - price
265
- shares = INVEST.to_i / price.to_i
266
- upside = (shares * pps).round(2)
267
- trade(ticker, signal, shares, price)
268
- else
269
- upside = ""
270
- end
271
-
272
- row << signal
273
- row << upside
274
-
275
- values << row
276
- end
166
+ require_relative 'analysis'
167
+ require_relative 'web'
277
168
 
278
- # debug_me{[
279
- # :result
280
- # ]}
281
-
282
-
283
- the_table = TTY::Table.new(headers, values)
284
-
285
- puts
286
- puts "Analysis as of Friday Close: #{end_date}"
287
-
288
- puts the_table.render(
289
- :unicode,
290
- {
291
- padding: [0, 0, 0, 0],
292
- alignments: [
293
- :left, # ticker
294
- :right, # adj close
295
- :center, # trend
296
- :right, # slope
297
- :right, # momentum
298
- :right, # rsi
299
- :center, # meaning / analysis
300
- :right, # macd
301
- :right, # target
302
- :center, # signal
303
- :right # upside
304
- ],
305
- }
306
- )
307
- puts
308
-
309
- TRADES_FILE.close
169
+ # First Load TTY-Option's command content with all available commands
170
+ # then these have access to the entire ObjectSpace ...
171
+ SQA::CLI.command SQA::CLI.names
172
+ SQA::CLI.example SQA::CLI.command_descriptions
310
173
 
data/lib/sqa/config.rb ADDED
@@ -0,0 +1,169 @@
1
+ # lib/sqa/config.rb
2
+
3
+ # The hierarchies of values should be:
4
+ # default
5
+ # envar ..... overrides default
6
+ # config file ..... overrides envar
7
+ # command line parameters ...... overrides config file
8
+
9
+ require 'hashie'
10
+ require 'yaml'
11
+ require 'json'
12
+ require 'toml-rb'
13
+
14
+
15
+ module SQA
16
+ class Config < Hashie::Dash
17
+ include Hashie::Extensions::Dash::PropertyTranslation
18
+ include Hashie::Extensions::Coercion
19
+ include Hashie::Extensions::Dash::PredefinedValues
20
+
21
+ property :config_file #, default: Nenv.home + "/.sqa.yml"
22
+ property :data_dir, default: Nenv.home + "/sqa_data"
23
+
24
+ # TODO: If no path is given, these files will be in
25
+ # data directory, otherwise, use the given path
26
+ property :portfolio_filename, from: :portfolio, default: "portfolio.csv"
27
+ property :trades_filename, from: :trades, default: "trades.csv"
28
+
29
+ property :log_level, default: :info, coerce: Symbol, values: %i[debug info warn error fatal]
30
+
31
+ # TODO: need a custom proc since there is no Boolean class in Ruby
32
+ property :debug, default: false #, coerce: Boolean
33
+ property :verbose, default: false #, coerce: Boolean
34
+
35
+ # TODO: use svggraph
36
+ property :plotting_library, from: :plot_lib, default: :gruff, coerce: Symbol
37
+ property :lazy_update, from: :lazy, default: false
38
+
39
+
40
+ coerce_key :debug, ->(v) do
41
+ case v
42
+ when String
43
+ !!(v =~ /\A(true|t|yes|y|1)\z/i)
44
+ when Numeric
45
+ !v.to_i.zero?
46
+ else
47
+ v == true
48
+ end
49
+ end
50
+
51
+ coerce_key :verbose, ->(v) do
52
+ case v
53
+ when String
54
+ !!(v =~ /\A(true|t|yes|y|1)\z/i)
55
+ when Numeric
56
+ !v.to_i.zero?
57
+ else
58
+ v == true
59
+ end
60
+ end
61
+
62
+ ########################################################
63
+ def initialize(a_hash={})
64
+ super(a_hash)
65
+ override_with_envars
66
+ end
67
+
68
+ def debug? = debug
69
+ def verbose? = verbose
70
+
71
+
72
+ ########################################################
73
+ def from_file
74
+ return if config_file.nil?
75
+
76
+ if File.exist?(config_file) &&
77
+ File.file?(config_file) &&
78
+ File.readable?(config_file)
79
+ type = File.extname(config_file).downcase
80
+ else
81
+ type = "invalid"
82
+ end
83
+
84
+ # TODO: arrange order in mostly often used
85
+
86
+ if ".json" == type
87
+ form_json
88
+
89
+ elsif %w[.yml .yaml].include?(type)
90
+ from_yaml
91
+
92
+ elsif ".toml" == type
93
+ from_toml
94
+
95
+ else
96
+ raise BadParameterError, "Invalid Config File: #{config_file}"
97
+ end
98
+ end
99
+
100
+ def dump_file
101
+ if config_file.nil?
102
+ raise BadParameterError, "No config file given"
103
+ end
104
+
105
+ if File.exist?(config_file) &&
106
+ File.file?(config_file) &&
107
+ File.writable?(config_file)
108
+ type = File.extname(config_file).downcase
109
+ else
110
+ type = "invalid"
111
+ end
112
+
113
+ if ".json" == type
114
+ dump_json
115
+
116
+ elsif %w[.yml .yaml].include?(type)
117
+ dump_yaml
118
+
119
+ elsif ".toml" == type
120
+ dump_toml
121
+
122
+ else
123
+ raise BadParameterError, "Invalid Config File: #{config_file}"
124
+ end
125
+ end
126
+
127
+
128
+ ########################################################
129
+ private
130
+
131
+ def override_with_envars(prefix = "SQA_")
132
+ keys.each do |key|
133
+ envar = ENV["#{prefix}#{key.to_s.upcase}"]
134
+ send("#{key}=", envar) unless envar.nil?
135
+ end
136
+ end
137
+
138
+
139
+ #####################################
140
+ ## override values from a config file
141
+
142
+ def from_json
143
+ incoming = ::JSON.load(File.open(config_file).read)
144
+ debug_me{[ :incoming ]}
145
+ end
146
+
147
+ def from_toml
148
+ incoming = TomlRB.load_file(config_file)
149
+ debug_me{[ :incoming ]}
150
+ end
151
+
152
+ def from_yaml
153
+ incoming = ::YAML.load_file(config_file)
154
+ debug_me{[ :incoming ]}
155
+ merge! incoming
156
+ end
157
+
158
+
159
+ #####################################
160
+ ## dump values to a config file
161
+
162
+ def as_hash = to_h.reject{|k, _| :config_file == k}
163
+ def dump_json = File.open(config_file, "w") { |f| f.write JSON.pretty_generate(as_hash)}
164
+ def dump_toml = File.open(config_file, "w") { |f| f.write TomlRB.dump(as_hash)}
165
+ def dump_yaml = File.open(config_file, "w") { |f| f.write as_hash.to_yaml}
166
+ end
167
+ end
168
+
169
+ SQA.config = SQA::Config.new
@@ -0,0 +1,23 @@
1
+ # lib/sqa/constants.rb
2
+
3
+ module SQA
4
+ module Constants
5
+ Signal = {
6
+ hold: 0,
7
+ buy: 1,
8
+ sell: 2
9
+ }.freeze
10
+
11
+ Trend = {
12
+ up: 0,
13
+ down: 1
14
+ }.freeze
15
+
16
+ Swing = {
17
+ valley: 0,
18
+ peak: 1,
19
+ }.freeze
20
+ end
21
+
22
+ include Constants
23
+ end
@@ -3,9 +3,13 @@
3
3
 
4
4
  class SQA::DataFrame < Daru::DataFrame
5
5
  class YahooFinance
6
- def self.from_csv(ticker)
7
- df = SQA::DataFrame.from_csv(ticker)
6
+ def self.load(filename, options={}, &block)
7
+ df = SQA::DataFrame.load(filename, options={}, &block)
8
8
 
9
+ # ASSUMPTION: This is the column headers from Yahoo Finance for
10
+ # CSV files. If the file type is something different from the
11
+ # same source, they may not be the same.
12
+ #
9
13
  new_names = {
10
14
  "Date" => :timestamp,
11
15
  "Open" => :open_price,
@@ -8,22 +8,20 @@ class SQA::DataFrame < Daru::DataFrame
8
8
  SQA::Config.data_dir + filename
9
9
  end
10
10
 
11
- def self.from_csv(ticker)
12
- df = super(path("#{ticker.downcase}.csv"))
13
- df[:ticker] = ticker
14
- df
15
- end
16
-
17
- def self.load(filename)
11
+ def self.load(filename, options={}, &block)
18
12
  source = path(filename)
19
13
  type = source.extname.downcase
20
14
 
21
15
  if ".csv" == type
22
- @df = Daru::DataFrame.from_csv(source)
16
+ from_csv(source, options={}, &block)
23
17
  elsif ".json" == type
24
- @df = Daru::DataFrame.from_json(source)
18
+ from_json(source, options={}, &block)
19
+ elsif %w[.txt .dat].include?(type)
20
+ from_plaintext(source, options={}, &block)
21
+ elsif ".xls" == type
22
+ from_excel(source, options={}, &block)
25
23
  else
26
- raise SQA::BadParamenterError, "supports csv or json only"
24
+ raise SQA::BadParamenterError, "un-suppod file type: #{type}"
27
25
  end
28
26
  end
29
27
  end