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.
- checksums.yaml +7 -0
- data/.gitignore +6 -0
- data/.rspec +2 -0
- data/Gemfile +3 -0
- data/Install +4 -0
- data/README.rdoc +23 -0
- data/Rakefile +13 -0
- data/bin/calc_profit +75 -0
- data/bin/do-demo.sh +7 -0
- data/bin/easters.rb +17 -0
- data/bin/tm-profit.rb +60 -0
- data/calc_profit.gemspec +39 -0
- data/examples/Trans.csv +92 -0
- data/examples/driver.tex +16 -0
- data/examples/lopez.csv +34 -0
- data/examples/profit.lp +93 -0
- data/examples/solution.out +814 -0
- data/lib/calc_profit.rb +14 -0
- data/lib/calc_profit/core_ext.rb +2 -0
- data/lib/calc_profit/core_ext/array.rb +10 -0
- data/lib/calc_profit/core_ext/string.rb +32 -0
- data/lib/calc_profit/latex_table_builder.rb +182 -0
- data/lib/calc_profit/match.rb +57 -0
- data/lib/calc_profit/table.rb +151 -0
- data/lib/calc_profit/transaction.rb +50 -0
- data/lib/calc_profit/transaction_group.rb +181 -0
- data/lib/calc_profit/version.rb +7 -0
- data/test/TestAddress.rb +23 -0
- data/test/TestDBase.rb +47 -0
- data/test/TestDate.rb +267 -0
- data/test/TestLog.rb +19 -0
- data/test/TestName.rb +110 -0
- data/test/TestRetriever.rb +38 -0
- data/test/TestString.rb +12 -0
- data/test/TestTeXString.rb +12 -0
- data/test/TestTransaction.rb +56 -0
- data/test/Trans.csv +92 -0
- data/test/tabdemo-tm.tex +37 -0
- data/test/tabdemo.tex +37 -0
- data/test/tables-tm.tex +235 -0
- data/test/tables.tex +235 -0
- data/test/test_helper.rb +38 -0
- metadata +222 -0
data/lib/calc_profit.rb
ADDED
@@ -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,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
|