calc_profit 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,14 @@
1
+ require 'active_support/all'
2
+ require 'fat_core'
3
+ require 'pathname'
4
+ require 'csv'
5
+
6
+ require 'calc_profit/core_ext'
7
+ require 'calc_profit/match'
8
+ require 'calc_profit/transaction'
9
+ require 'calc_profit/transaction_group'
10
+ require 'calc_profit/table'
11
+ require 'calc_profit/latex_table_builder'
12
+
13
+ require 'byebug'
14
+ require 'pry'
@@ -0,0 +1,2 @@
1
+ require 'calc_profit/core_ext/string'
2
+ require 'calc_profit/core_ext/array'
@@ -0,0 +1,10 @@
1
+ class Array
2
+ # Duplicate each element of an Array into a new Array
3
+ def dup2
4
+ newa = []
5
+ self.each { |e|
6
+ newa << e.dup
7
+ }
8
+ newa
9
+ end
10
+ end
@@ -0,0 +1,32 @@
1
+ class String
2
+ def dequote
3
+ if self.strip =~ /^"/
4
+ self.sub(/^\s*"?([^"]*)"?\s*$/, '\1')
5
+ else
6
+ self
7
+ end
8
+ end
9
+
10
+ # Remove leading and trailing white space and compress internal runs of
11
+ # white space to a single space.
12
+ def clean
13
+ self.strip.squeeze(' ')
14
+ end
15
+
16
+ # Format the string according to the given format. A format of n, where n
17
+ # is an integer, formats the string as a number with embedded commas and
18
+ # rounded to n decimal places. A nil format just returns the string.
19
+ def format_by(format)
20
+ case format
21
+ when Integer
22
+ val = gsub(/[$,]/, '')
23
+ if val.number?
24
+ val.to_f.commas(format)
25
+ else
26
+ self
27
+ end
28
+ else
29
+ self
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,182 @@
1
+ module CalcProfit
2
+ class LatexTableBuilder
3
+ attr_reader :table
4
+
5
+ def initialize(table)
6
+ @table = table
7
+ end
8
+
9
+ # Return a LaTeX longtable string for the table showing the columns in the
10
+ # order given by the include parameter as an array of symbols representing
11
+ # table headers. By default, all columns are included.
12
+
13
+ # The columns will be arranged in the order given in include. Columns not
14
+ # found in the include array will not be displayed. Also, and columns
15
+ # named in the exclude argument will not be displayed. Thus, if only an
16
+ # exclude parameter is given, all the columns will be displayed other than
17
+ # those named in the exclude parameter.
18
+ #
19
+ # An optional caption can be given as a string in the caption parameter.
20
+ #
21
+ # The rows of the table will be sorted according to the column (if a
22
+ # symbol) or columns (if an array of symbols) given in sort_on. By
23
+ # default, they will be sorted on the first column named in the columns
24
+ # parameter.
25
+ #
26
+ # A footer row containing the totals of columns named in the total_on
27
+ # parameter, which can be a symbol or an array of symbols.
28
+ #
29
+ # All columns will be right-aligned unless a different spec is given in
30
+ # the align parameter, which must be a hash keyed on the column symbol
31
+ # and having a value that is a valid LaTeX column spec. For example,
32
+ # align: { date: 'c', ref: 'l' } will cause the items in the date
33
+ # column to be centered and the items in the ref column to be
34
+ # left-aligned.
35
+ #
36
+ # All table items will be formatted as is unless a format is given in the
37
+ # formats parameter. It should be a hash that contains a formatting
38
+ # directive for any columns that need additional formatting. As of now,
39
+ # the only formatting directive recognized is an integer to specify that
40
+ # the column items should be formatted as a number grouped with commas and
41
+ # rounded to the number of places given in the number. For example,
42
+ # formats: { shares: 0, price: 5 } will cause the numbers in those columns
43
+ # to be rounded to 0 and 5 places respectively and have grouping commas
44
+ # embedded in them.
45
+ #
46
+ # Finally, the whole table can be returned as a macro definition named by
47
+ # the define_as: parameter if given. Otherwise, return the direct table
48
+ # code.
49
+ def build(include: table.rows[0].keys, exclude: [],
50
+ caption: nil, sort_on: include[0], total_on: nil,
51
+ align: {}, formats: {}, define_as: nil)
52
+
53
+ # Allow include and exclude to be a single symbol or an array of symbols
54
+ # and compute the displayed columns.
55
+ columns = [include].flatten
56
+ exclude = [exclude].flatten
57
+ columns = include - exclude
58
+
59
+ # By default, right-align all columns
60
+ specs = {}
61
+ columns.each do |col|
62
+ specs[col] = align[col] || 'r'
63
+ end
64
+
65
+ # Allow sort_on and total_on to be a single symbol or an array of
66
+ # symbols
67
+ sort_on = [sort_on].flatten
68
+ total_on = [total_on].flatten if total_on
69
+
70
+ # Macro definition
71
+ ltable = ''
72
+ if define_as
73
+ ltable += "\\newcommand{\\#{define_as}}{\n"
74
+ end
75
+
76
+ # Table environment
77
+ spec_string = ''
78
+ columns.each do |col|
79
+ spec_string += specs[col]
80
+ end
81
+ ltable += <<-EOT.clean
82
+ \\begin{longtable}{#{spec_string}}
83
+ \\hline\\hline\\\\
84
+ EOT
85
+ ltable += "\n"
86
+
87
+ # Caption
88
+ ltable += "\\caption{#{caption.tex_quote}}\\\\\n" if caption
89
+
90
+ # Header
91
+ ltable += tex_header(columns)
92
+ ltable += <<-'EOT'.clean
93
+ \\\hline\\[0.5ex]
94
+ \endhead
95
+ EOT
96
+ ltable += "\n"
97
+
98
+ # Footer
99
+ if total_on
100
+ ltable += tex_row(total_row(total_on, columns), columns,
101
+ formats: formats, bold: true)
102
+ end
103
+ ltable += <<-'EOT'.clean
104
+ \endlastfoot
105
+ EOT
106
+ ltable += "\n"
107
+
108
+ # Sorted, formated Body
109
+ sort_rows_on(sort_on).each do |row|
110
+ ltable += tex_row(row, columns, formats: formats)
111
+ end
112
+
113
+ # Close environment
114
+ ltable += <<-'EOT'.clean
115
+ \end{longtable}
116
+ EOT
117
+
118
+ # Close macro
119
+ if define_as
120
+ ltable += '}'
121
+ end
122
+ ltable
123
+ end
124
+
125
+ private
126
+
127
+ def tex_header(columns)
128
+ head = ''
129
+ columns.each do |col|
130
+ head += "\\multicolumn{1}{c}{\\textbf{#{col.entitle.tex_quote}}}&\n"
131
+ end
132
+ head = head.sub(/&\n\z/, '')
133
+ head += "\\\\\n"
134
+ end
135
+
136
+ # Return the rows of the table sorted on the possibly multiple keys given
137
+ # in columns.
138
+ def sort_rows_on(columns)
139
+ table.rows.sort do |r1, r2|
140
+ key1 = columns.each.map { |col| r1[col]}
141
+ key2 = columns.each.map { |col| r2[col]}
142
+ key1 <=> key2
143
+ end
144
+ end
145
+
146
+ # Return a constructed row of column totals for the columns named in
147
+ # total_on with a blank entry for all other columns.
148
+ def total_row(total_on, columns)
149
+ total_row = {}
150
+ first_col = true
151
+ columns.each do |col|
152
+ if first_col
153
+ total_row[col] = 'Totals'
154
+ first_col = false
155
+ next
156
+ end
157
+ if total_on.include?(col)
158
+ total_row[col] = table.column_sum(col).to_s
159
+ else
160
+ total_row[col] = ''
161
+ end
162
+ end
163
+ total_row
164
+ end
165
+
166
+ # Return a string for a TeX table row for the given columns, formatted
167
+ # according to the given formats.
168
+ def tex_row(row, columns, formats: {}, bold: false)
169
+ row_string = ''
170
+ columns.each do |col|
171
+ item = row[col].format_by(formats[col]).tex_quote
172
+ if bold
173
+ row_string += "\\textbf{#{item}}&"
174
+ else
175
+ row_string += "#{item}&"
176
+ end
177
+ end
178
+ row_string = row_string.sub(/&\z/, '')
179
+ row_string += "\\\\\n"
180
+ end
181
+ end
182
+ end
@@ -0,0 +1,57 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ module CalcProfit
4
+ class Match
5
+ attr_reader :purchase, :sale
6
+
7
+ def initialize(p, s)
8
+ case p
9
+ when Transaction
10
+ if p.code == 'P'
11
+ @purchase = p
12
+ else
13
+ raise "First argument to Match (#{p}) not a purchase Transaction."
14
+ end
15
+ else
16
+ raise "First argument to Match (#{p}) not a purchase Transaction."
17
+ end
18
+
19
+ case s
20
+ when Transaction
21
+ if s.code == 'S'
22
+ @sale = s
23
+ else
24
+ raise "Second argument to Match (#{s}) not a sale Transaction."
25
+ end
26
+ else
27
+ raise "Second argument to Match (#{s}) not a sale Transaction."
28
+ end
29
+
30
+ if p.price >= s.price
31
+ raise "No profit from purchase @ #{p.price} and sale @ #{s.price} supplied to Match."
32
+ end
33
+
34
+ unless p.date.within_6mos_of?(s.date)
35
+ raise "Match purchase and sale not within 6 months of each other."
36
+ end
37
+ end
38
+
39
+ def profit
40
+ p = ((@sale.price - @purchase.price) *
41
+ [@sale.shares, @purchase.shares].min)
42
+ (p * 100.0).round / 100.0
43
+ end
44
+
45
+ def table_row
46
+ row = {}
47
+ purchase.table_row.each_pair do |k, v|
48
+ row[(k.entitle + ' P').as_sym] = v
49
+ end
50
+ sale.table_row.each_pair do |k, v|
51
+ row[(k.entitle + ' S').as_sym] = v
52
+ end
53
+ row[:profit] = profit.to_s
54
+ row
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,151 @@
1
+ module CalcProfit
2
+
3
+ # A container for a two-dimensional table that can be used as input of a set
4
+ # of transactions and output for results of calculations. All cells in the
5
+ # table are kept as strings with no attempt to determine their type. The
6
+ # table is maintained as an array of hashes accessible from the Table#rows
7
+ # method.
8
+ #
9
+ # Initialize a new table with the name of a .csv file or a .org file. The
10
+ # initializer will populate the table with the first csv or org table
11
+ # structure found in either file. You can also pass in an IO object for
12
+ # either type of file, but in that case, you need to specify '.csv' or
13
+ # '.ext' as the second argument to tell it what kind of file format to
14
+ # expect. Finally, the constructor will also take an array of arrays or an
15
+ # array of hashes. In the former case, if the second array's first element
16
+ # is a string that looks like a rule spearator, '-----------',
17
+ # '+----------', etc., the headers will be taken from the first array. In
18
+ # the latter case, the keys of the hashes will be used as headers. It is
19
+ # assumed that all the hashes have the same keys.
20
+ #
21
+ # In the resulting array of hashes, the headers are converted into symbols,
22
+ # with all spaces converted to underscore and everything down-cased. So, the
23
+ # heading, 'Two Words' becomes the hash key :two_words.
24
+ #
25
+ class Table
26
+ attr_reader :rows
27
+
28
+ def initialize(input = nil, ext = '.csv')
29
+ case input
30
+ when NilClass
31
+ @rows = []
32
+ when IO, StringIO
33
+ case ext
34
+ when '.csv'
35
+ @rows = read_table_from_csv(input)
36
+ when '.org'
37
+ @rows = read_table_from_org(input)
38
+ else
39
+ raise "Don't know how to read a #{ext} file."
40
+ end
41
+ when String
42
+ ext = File.extname(input).downcase
43
+ File.open(input, 'r') do |io|
44
+ case ext
45
+ when '.csv'
46
+ @rows = read_table_from_csv(io)
47
+ when '.org'
48
+ @rows = read_table_from_org(io)
49
+ else
50
+ raise "Don't know how to read a #{ext} file."
51
+ end
52
+ end
53
+ when Array
54
+ case input[0]
55
+ when Array
56
+ @rows = read_table_from_array_of_arrays(input)
57
+ when Hash
58
+ @rows = read_table_from_array_of_hashes(input)
59
+ else
60
+ raise ArgumentError, "Table object initialized with unknown data type"
61
+ end
62
+ else
63
+ raise ArgumentError, "Table object initialized with unknown data type"
64
+ end
65
+ end
66
+
67
+ def <<(row)
68
+ @rows << row
69
+ end
70
+
71
+ def read_table_from_csv(io)
72
+ rows = []
73
+ ::CSV.new(io, headers: true, header_converters: :symbol,
74
+ skip_blanks: true).each do |row|
75
+ rows << row.to_hash
76
+ end
77
+ rows
78
+ end
79
+
80
+ # Form rows of table by reading the first table found in the org file.
81
+ def read_table_from_org(io)
82
+ # For determining when we're looking at lines of the table, after
83
+ # the table with name /table_name/ has been spotted.
84
+ table_re = /\A\s*\|/
85
+
86
+ rows = []
87
+ table_found = false
88
+ io.each do |line|
89
+ if !table_found
90
+ # Skip through the file until a table is found
91
+ if line =~ table_re
92
+ table_found = true
93
+ else
94
+ next
95
+ end
96
+ end
97
+ break unless line =~ table_re
98
+ line = line.sub(/\A\s*\|/, '').sub(/\|\s*\z/, '')
99
+ rows << line.split('|')
100
+ end
101
+ read_table_from_array_of_arrays(rows)
102
+ end
103
+
104
+ def read_table_from_array_of_arrays(rows)
105
+ hrule_re = /\A\s*[-+]+\s*\z/
106
+ headers = []
107
+ first_data_row = 0
108
+ if rows[1][0] =~ hrule_re
109
+ # Use first row as headers
110
+ headers = rows[0].map(&:as_sym)
111
+ first_data_row = 2
112
+ else
113
+ headers = (1..rows[0].size).to_a.map{|k| "col#{k}".as_sym }
114
+ end
115
+ hash_rows = []
116
+ rows[first_data_row..-1].each do |row|
117
+ row = row.map{ |s| s.to_s.strip }
118
+ hash_rows << Hash[headers.zip(row)]
119
+ end
120
+ hash_rows
121
+ end
122
+
123
+ def read_table_from_array_of_hashes(rows)
124
+ hash_rows = []
125
+ rows.each do |row|
126
+ hash = {}
127
+ row.each_pair do |k, v|
128
+ case k
129
+ when String
130
+ key = k.as_sym
131
+ when Symbol
132
+ key = k
133
+ else
134
+ key = k.to_s.as_sym
135
+ end
136
+ hash[key] = v.to_s.strip
137
+ end
138
+ hash_rows << hash
139
+ end
140
+ hash_rows
141
+ end
142
+
143
+ def headers
144
+ rows[0].keys
145
+ end
146
+
147
+ def column_sum(col)
148
+ rows.map{ |r| r[col].gsub(/[,$]/, '').to_f }.inject(:+)
149
+ end
150
+ end
151
+ end