rvgp 0.3.2
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.gitignore +8 -0
- data/.rubocop.yml +23 -0
- data/LICENSE +504 -0
- data/README.md +223 -0
- data/Rakefile +32 -0
- data/bin/rvgp +8 -0
- data/lib/rvgp/application/config.rb +159 -0
- data/lib/rvgp/application/descendant_registry.rb +122 -0
- data/lib/rvgp/application/status_output.rb +139 -0
- data/lib/rvgp/application.rb +170 -0
- data/lib/rvgp/base/command.rb +457 -0
- data/lib/rvgp/base/grid.rb +531 -0
- data/lib/rvgp/base/reader.rb +29 -0
- data/lib/rvgp/base/reconciler.rb +434 -0
- data/lib/rvgp/base/validation.rb +261 -0
- data/lib/rvgp/commands/cashflow.rb +160 -0
- data/lib/rvgp/commands/grid.rb +70 -0
- data/lib/rvgp/commands/ireconcile.rb +95 -0
- data/lib/rvgp/commands/new_project.rb +296 -0
- data/lib/rvgp/commands/plot.rb +41 -0
- data/lib/rvgp/commands/publish_gsheets.rb +83 -0
- data/lib/rvgp/commands/reconcile.rb +58 -0
- data/lib/rvgp/commands/rotate_year.rb +202 -0
- data/lib/rvgp/commands/validate_journal.rb +59 -0
- data/lib/rvgp/commands/validate_system.rb +44 -0
- data/lib/rvgp/commands.rb +160 -0
- data/lib/rvgp/dashboard.rb +252 -0
- data/lib/rvgp/fakers/fake_feed.rb +245 -0
- data/lib/rvgp/fakers/fake_journal.rb +57 -0
- data/lib/rvgp/fakers/fake_reconciler.rb +88 -0
- data/lib/rvgp/fakers/faker_helpers.rb +25 -0
- data/lib/rvgp/gem.rb +80 -0
- data/lib/rvgp/journal/commodity.rb +453 -0
- data/lib/rvgp/journal/complex_commodity.rb +214 -0
- data/lib/rvgp/journal/currency.rb +101 -0
- data/lib/rvgp/journal/journal.rb +141 -0
- data/lib/rvgp/journal/posting.rb +156 -0
- data/lib/rvgp/journal/pricer.rb +267 -0
- data/lib/rvgp/journal.rb +24 -0
- data/lib/rvgp/plot/gnuplot.rb +478 -0
- data/lib/rvgp/plot/google-drive/output_csv.rb +44 -0
- data/lib/rvgp/plot/google-drive/output_google_sheets.rb +434 -0
- data/lib/rvgp/plot/google-drive/sheet.rb +67 -0
- data/lib/rvgp/plot.rb +293 -0
- data/lib/rvgp/pta/hledger.rb +237 -0
- data/lib/rvgp/pta/ledger.rb +308 -0
- data/lib/rvgp/pta.rb +311 -0
- data/lib/rvgp/reconcilers/csv_reconciler.rb +424 -0
- data/lib/rvgp/reconcilers/journal_reconciler.rb +41 -0
- data/lib/rvgp/reconcilers/shorthand/finance_gem_hacks.rb +48 -0
- data/lib/rvgp/reconcilers/shorthand/international_atm.rb +152 -0
- data/lib/rvgp/reconcilers/shorthand/investment.rb +144 -0
- data/lib/rvgp/reconcilers/shorthand/mortgage.rb +195 -0
- data/lib/rvgp/utilities/grid_query.rb +190 -0
- data/lib/rvgp/utilities/yaml.rb +131 -0
- data/lib/rvgp/utilities.rb +44 -0
- data/lib/rvgp/validations/balance_validation.rb +68 -0
- data/lib/rvgp/validations/duplicate_tags_validation.rb +48 -0
- data/lib/rvgp/validations/uncategorized_validation.rb +15 -0
- data/lib/rvgp.rb +66 -0
- data/resources/README.MD/2022-cashflow-google.png +0 -0
- data/resources/README.MD/2022-cashflow.png +0 -0
- data/resources/README.MD/all-wealth-growth-google.png +0 -0
- data/resources/README.MD/all-wealth-growth.png +0 -0
- data/resources/gnuplot/default.yml +80 -0
- data/resources/i18n/en.yml +192 -0
- data/resources/iso-4217-currencies.json +171 -0
- data/resources/skel/Rakefile +5 -0
- data/resources/skel/app/grids/cashflow_grid.rb +27 -0
- data/resources/skel/app/grids/monthly_income_and_expenses_grid.rb +25 -0
- data/resources/skel/app/grids/wealth_growth_grid.rb +35 -0
- data/resources/skel/app/plots/cashflow.yml +33 -0
- data/resources/skel/app/plots/monthly-income-and-expenses.yml +17 -0
- data/resources/skel/app/plots/wealth-growth.yml +20 -0
- data/resources/skel/config/csv-format-acme-checking.yml +9 -0
- data/resources/skel/config/google-secrets.yml +5 -0
- data/resources/skel/config/rvgp.yml +0 -0
- data/resources/skel/journals/prices.db +0 -0
- data/rvgp.gemspec +6 -0
- data/test/assets/ledger_total_monthly_liabilities_with_empty.xml +383 -0
- data/test/assets/ledger_total_monthly_liabilities_with_empty2.xml +428 -0
- data/test/test_command_base.rb +61 -0
- data/test/test_commodity.rb +270 -0
- data/test/test_csv_reconciler.rb +60 -0
- data/test/test_currency.rb +24 -0
- data/test/test_fake_feed.rb +228 -0
- data/test/test_fake_journal.rb +98 -0
- data/test/test_fake_reconciler.rb +60 -0
- data/test/test_journal_parse.rb +545 -0
- data/test/test_ledger.rb +102 -0
- data/test/test_plot.rb +133 -0
- data/test/test_posting.rb +50 -0
- data/test/test_pricer.rb +139 -0
- data/test/test_pta_adapter.rb +575 -0
- data/test/test_utilities.rb +45 -0
- metadata +268 -0
@@ -0,0 +1,453 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'bigdecimal'
|
4
|
+
|
5
|
+
module RVGP
|
6
|
+
class Journal
|
7
|
+
# This abstraction defines a simple commodity entry, as would be found in a pta journal.
|
8
|
+
# Such commodities can appear in the form of currency, such as '$ 1.30' or in any other
|
9
|
+
# format that hledger and ledger parse. ie '1 HOUSE'.
|
10
|
+
#
|
11
|
+
# There's a lot of additional functionality provided by this class, including math
|
12
|
+
# related helper functions.
|
13
|
+
#
|
14
|
+
# NOTE: the easiest way to create a commodity in your code, is by way of the
|
15
|
+
# provided {String#to_commodity} method. Such as: '$ 1.30'.to_commodity.
|
16
|
+
#
|
17
|
+
# Units of a commodity are stored in int's, with precision. This ensures that
|
18
|
+
# there is no potential for floating point precision errors, affecting these
|
19
|
+
# commodities.
|
20
|
+
#
|
21
|
+
# A number of constants, relating to the built-in support of various currencies, are
|
22
|
+
# available as part of RVGP, in the form of the
|
23
|
+
# {https://github.com/brighton36/rra/blob/main/resources/iso-4217-currencies.json iso-4217-currencies.json}
|
24
|
+
# file. Which, is loaded automatically during initialization.
|
25
|
+
#
|
26
|
+
# @attr_reader [String] code The code of this commodity. Which, may be the same as :alphabetic_code, or, may take
|
27
|
+
# the form of symbol. (ie '$'). This code is used to render the commodity to strings.
|
28
|
+
# @attr_reader [String] alphabetic_code The ISO-4217 'Alphabetic Code' of this commodity. This code is used for
|
29
|
+
# various non-rendering functions. (Equality testing, Conversion lookups...)
|
30
|
+
# @attr_reader [Integer] quantity The number of units, of this currency, before applying a fractional representation
|
31
|
+
# (Ie "$ 2.89" is stored in the form of :quantity 289)
|
32
|
+
# @attr_reader [Integer] precision The exponent of the characteristic, which is used to separate the mantissa
|
33
|
+
# from the significand.
|
34
|
+
class Commodity
|
35
|
+
attr_accessor :quantity, :code, :alphabetic_code, :precision
|
36
|
+
|
37
|
+
# @!visibility private
|
38
|
+
MATCH_AMOUNT = '([-]?[ ]*?[\d\,]+(?:\\.[\d]+|))'
|
39
|
+
|
40
|
+
# @!visibility private
|
41
|
+
MATCH_CODE = '(?:(?<!\\\\)\"(.+)(?<!\\\\)\"|([^ \-\d]+))'
|
42
|
+
|
43
|
+
# @!visibility private
|
44
|
+
MATCH_COMMODITY = ['\\A(?:', MATCH_CODE, '[ ]*?', MATCH_AMOUNT, '|', MATCH_AMOUNT, '[ ]*?', MATCH_CODE].freeze
|
45
|
+
|
46
|
+
# @!visibility private
|
47
|
+
MATCH_COMMODITY_WITHOUT_REMAINDER = Regexp.new((MATCH_COMMODITY + [')\\Z']).join)
|
48
|
+
|
49
|
+
# @!visibility private
|
50
|
+
MATCH_COMMODITY_WITH_REMAINDER = Regexp.new((MATCH_COMMODITY + [')(.*?)\\Z']).join)
|
51
|
+
|
52
|
+
# This appears to be a ruby limit, in float's
|
53
|
+
# @!visibility private
|
54
|
+
MAX_DECIMAL_DIGITS = 17
|
55
|
+
|
56
|
+
# This error is typically thrown when a commodity is evaluated against an rvalue that doesn't
|
57
|
+
# match the commodity of the lvalue.
|
58
|
+
class ConversionError < StandardError; end
|
59
|
+
|
60
|
+
# There are a handful of code paths that are currently unimplemented. Usually these are
|
61
|
+
# unimplemented because the interpretation of the request is ambiguous.
|
62
|
+
class UnimplementedError < StandardError; end
|
63
|
+
|
64
|
+
# Create a commodity, from the constituent parts
|
65
|
+
# @param [String] code see {Commodity#code}
|
66
|
+
# @param [String] alphabetic_code see {Commodity#alphabetic_code}
|
67
|
+
# @param [Integer] quantity see {Commodity#quantity}
|
68
|
+
# @param [Integer] precision see {Commodity#precision}
|
69
|
+
def initialize(code, alphabetic_code, quantity, precision)
|
70
|
+
@code = code
|
71
|
+
@alphabetic_code = alphabetic_code
|
72
|
+
@quantity = quantity.to_i
|
73
|
+
@precision = precision.to_i
|
74
|
+
end
|
75
|
+
|
76
|
+
# Render the :quantity, to a string. This is output without code notation, and merely
|
77
|
+
# expressed the quantity with the expected symbols (commas, periods) .
|
78
|
+
# @param [Hash] options formatting specifiers, affecting what output is produced
|
79
|
+
# @option options [Integer] precision Use the provided precision, instead of the :precision accessor
|
80
|
+
# @option options [TrueClass,FalseClass] commatize (false) Whether or not to insert commas in the output, between
|
81
|
+
# every three digits, in the characteristic
|
82
|
+
# @return [String] The formatted quantity
|
83
|
+
def quantity_as_s(options = {})
|
84
|
+
characteristic, mantissa = if options.key? :precision
|
85
|
+
round(options[:precision]).quantity_as_decimal_pair
|
86
|
+
else
|
87
|
+
quantity_as_decimal_pair
|
88
|
+
end
|
89
|
+
|
90
|
+
characteristic = characteristic.to_s
|
91
|
+
to_precision = options[:precision] || precision
|
92
|
+
mantissa = to_precision.positive? ? format("%0#{to_precision}d", mantissa) : nil
|
93
|
+
|
94
|
+
characteristic = characteristic.reverse.gsub(/(\d{3})(?=\d)/, '\\1,').reverse if options[:commatize]
|
95
|
+
|
96
|
+
[negative? ? '-' : nil, characteristic, mantissa ? '.' : nil, mantissa].compact.join
|
97
|
+
end
|
98
|
+
|
99
|
+
# Returns the quantity component of the commodity, as a BigDecimal
|
100
|
+
# @return [BigDecimal]
|
101
|
+
def quantity_as_bigdecimal
|
102
|
+
BigDecimal quantity_as_s
|
103
|
+
end
|
104
|
+
|
105
|
+
# This returns the characteristic and mantissa for our quantity, given our precision,
|
106
|
+
# note that we do not return the +/- signage. That information is destroyed here
|
107
|
+
# @return [Array<Integer>] A two-value array, with characteristic at [0], and fraction at [1]
|
108
|
+
def quantity_as_decimal_pair
|
109
|
+
characteristic = quantity.abs.to_i / (10**precision)
|
110
|
+
[characteristic, quantity.abs.to_i - (characteristic * (10**precision))]
|
111
|
+
end
|
112
|
+
|
113
|
+
# Render the commodity to a string, in the form it would appear in a journal. This output
|
114
|
+
# includes the commodity code, as well as a period and, optionally commas.
|
115
|
+
# @param [Hash] options formatting specifiers, affecting what output is produced
|
116
|
+
# @option options [Integer] precision Use the provided precision, instead of the :precision accessor
|
117
|
+
# @option options [TrueClass,FalseClass] commatize (false) Whether or not to insert commas in the output, between
|
118
|
+
# every three digits, in the characteristic
|
119
|
+
# @option options [TrueClass,FalseClass] no_code (false) If true, the code is omitted in the output
|
120
|
+
# @return [String] The formatted quantity
|
121
|
+
def to_s(options = {})
|
122
|
+
ret = [quantity_as_s(options)]
|
123
|
+
if code && !options[:no_code]
|
124
|
+
operand = code.count(' ').positive? ? ['"', code, '"'].join : code
|
125
|
+
code.length == 1 ? ret.unshift(operand) : ret.push(operand)
|
126
|
+
end
|
127
|
+
ret.join(' ')
|
128
|
+
end
|
129
|
+
|
130
|
+
# Returns the quantity component of the commodity, after being adjusted for :precision, as a Float. Consider
|
131
|
+
# using {#quantity_as_bigdecimal} instead.
|
132
|
+
# @return [Float]
|
133
|
+
def to_f
|
134
|
+
quantity_as_s.to_f
|
135
|
+
end
|
136
|
+
|
137
|
+
# Returns whether or not the quantity is greater than zero.
|
138
|
+
# @return [TrueClass,FalseClass] yes or no
|
139
|
+
def positive?
|
140
|
+
quantity.positive?
|
141
|
+
end
|
142
|
+
|
143
|
+
# Returns whether or not the quantity is less than zero.
|
144
|
+
# @return [TrueClass,FalseClass] yes or no
|
145
|
+
def negative?
|
146
|
+
quantity.negative?
|
147
|
+
end
|
148
|
+
|
149
|
+
# Multiply the quantity by -1. This mutates the state of self.
|
150
|
+
# @return [RVGP::Journal::Commodity] self, after the transformation is applied
|
151
|
+
def invert!
|
152
|
+
@quantity *= -1
|
153
|
+
self
|
154
|
+
end
|
155
|
+
|
156
|
+
# Returns a copy of the current Commodity, with the absolute value of quanity.
|
157
|
+
# @return [RVGP::Journal::Commodity] self, with quantity.abs applied
|
158
|
+
def abs
|
159
|
+
RVGP::Journal::Commodity.new code, alphabetic_code, quantity.abs, precision
|
160
|
+
end
|
161
|
+
|
162
|
+
# This method returns a new Commodity, with :floor applied to its :quantity.
|
163
|
+
# @param [Integer] to_digit Which digit to floor to
|
164
|
+
# @return [RVGP::Journal::Commodity] A new copy of self, with quantity :floor'd
|
165
|
+
def floor(to_digit)
|
166
|
+
round_or_floor to_digit, :floor
|
167
|
+
end
|
168
|
+
|
169
|
+
# This method returns a new Commodity, with :round applied to its :quantity.
|
170
|
+
# @param [Integer] to_digit Which digit to round to
|
171
|
+
# @return [RVGP::Journal::Commodity] A new copy of self, with quantity :rounded
|
172
|
+
def round(to_digit)
|
173
|
+
round_or_floor to_digit, :round
|
174
|
+
end
|
175
|
+
|
176
|
+
# @!method >(rvalue)
|
177
|
+
# Ensure that rvalue is a commodity. Then return a boolean indicating whether self.quantity
|
178
|
+
# is greater than rvalue's quantity.
|
179
|
+
# @param [RVGP::Journal::Commodity] rvalue Another commodity to compare our quantity to
|
180
|
+
# @return [TrueClass,FalseClass] Result of comparison.
|
181
|
+
|
182
|
+
# @!method <(rvalue)
|
183
|
+
# Ensure that rvalue is a commodity. Then return a boolean indicating whether self.quantity
|
184
|
+
# is less than rvalue's quantity.
|
185
|
+
# @param [RVGP::Journal::Commodity] rvalue Another commodity to compare our quantity to
|
186
|
+
# @return [TrueClass,FalseClass] Result of comparison.
|
187
|
+
|
188
|
+
# @!method <=>(rvalue)
|
189
|
+
# Ensure that rvalue is a commodity. Then returns an integer indicating whether self.quantity
|
190
|
+
# is (spaceship) rvalue's quantity. More specifically: -1 on <, 0 on ==, 1 on >.
|
191
|
+
# @param [RVGP::Journal::Commodity] rvalue Another commodity to compare our quantity to
|
192
|
+
# @return [Integer] Result of comparison: -1, 0, or 1.
|
193
|
+
|
194
|
+
# @!method >=(rvalue)
|
195
|
+
# Ensure that rvalue is a commodity. Then return a boolean indicating whether self.quantity
|
196
|
+
# is greater than or equal to rvalue's quantity.
|
197
|
+
# @param [RVGP::Journal::Commodity] rvalue Another commodity to compare our quantity to
|
198
|
+
# @return [TrueClass,FalseClass] Result of comparison.
|
199
|
+
|
200
|
+
# @!method <=(rvalue)
|
201
|
+
# Ensure that rvalue is a commodity. Then return a boolean indicating whether self.quantity
|
202
|
+
# is less than or equal to rvalue's quantity.
|
203
|
+
# @param [RVGP::Journal::Commodity] rvalue Another commodity to compare our quantity to
|
204
|
+
# @return [TrueClass,FalseClass] Result of comparison.
|
205
|
+
|
206
|
+
# @!method ==(rvalue)
|
207
|
+
# Ensure that rvalue is a commodity. Then return a boolean indicating whether self.quantity
|
208
|
+
# is equal to rvalue's quantity.
|
209
|
+
# @param [RVGP::Journal::Commodity] rvalue Another commodity to compare our quantity to
|
210
|
+
# @return [TrueClass,FalseClass] Result of comparison.
|
211
|
+
|
212
|
+
# @!method !=(rvalue)
|
213
|
+
# Ensure that rvalue is a commodity. Then return a boolean indicating whether self.quantity
|
214
|
+
# is not equal to rvalue's quantity.
|
215
|
+
# @param [RVGP::Journal::Commodity] rvalue Another commodity to compare our quantity to
|
216
|
+
# @return [TrueClass,FalseClass] Result of comparison.
|
217
|
+
%i[> < <=> >= <= == !=].each do |operation|
|
218
|
+
define_method(operation) do |rvalue|
|
219
|
+
assert_commodity rvalue
|
220
|
+
|
221
|
+
lquantity, rquantity = quantities_denominated_against rvalue
|
222
|
+
|
223
|
+
lquantity.send operation, rquantity
|
224
|
+
end
|
225
|
+
end
|
226
|
+
|
227
|
+
# @!method *(rvalue)
|
228
|
+
# If the rvalue is a commodity, assert that we share the same commodity code, and if so
|
229
|
+
# multiple our quantity by the rvalue quantity. If rvalue is numeric, multiply our quantity
|
230
|
+
# by this numeric.
|
231
|
+
# @param [RVGP::Journal::Commodity,Numeric] rvalue A multiplicand
|
232
|
+
# @return [RVGP::Journal::Commodity] A new Commodity, composed of self.code, and the resulting quantity
|
233
|
+
|
234
|
+
# @!method /(rvalue)
|
235
|
+
# If the rvalue is a commodity, assert that we share the same commodity code, and if so
|
236
|
+
# divide our quantity by the rvalue quantity. If rvalue is numeric, divide our quantity
|
237
|
+
# by this numeric.
|
238
|
+
# @param [RVGP::Journal::Commodity,Numeric] rvalue A divisor
|
239
|
+
# @return [RVGP::Journal::Commodity] A new Commodity, composed of self.code, and the resulting quantity
|
240
|
+
%i[* /].each do |operation|
|
241
|
+
define_method(operation) do |rvalue|
|
242
|
+
result = if rvalue.is_a? Numeric
|
243
|
+
# These mul/divs are often "Divide by half" "Multiply by X" instructions
|
244
|
+
# for which the rvalue is not, and should not be, a commodity.
|
245
|
+
quantity_as_bigdecimal.send operation, rvalue
|
246
|
+
else
|
247
|
+
assert_commodity rvalue
|
248
|
+
|
249
|
+
raise UnimplementedError
|
250
|
+
end
|
251
|
+
|
252
|
+
RVGP::Journal::Commodity.from_symbol_and_amount code, result.round(MAX_DECIMAL_DIGITS).to_s('F')
|
253
|
+
end
|
254
|
+
end
|
255
|
+
|
256
|
+
# @!method +(rvalue)
|
257
|
+
# If the rvalue is a commodity, assert that we share the same commodity code, and if so
|
258
|
+
# sum our quantity with the rvalue quantity.
|
259
|
+
# @param [RVGP::Journal::Commodity] rvalue An operand
|
260
|
+
# @return [RVGP::Journal::Commodity] A new Commodity, composed of self.code, and the resulting quantity
|
261
|
+
|
262
|
+
# @!method -(rvalue)
|
263
|
+
# If the rvalue is a commodity, assert that we share the same commodity code, and if so
|
264
|
+
# subtract the rvalue quantity from our quantity.
|
265
|
+
# @param [RVGP::Journal::Commodity] rvalue An operand
|
266
|
+
# @return [RVGP::Journal::Commodity] A new Commodity, composed of self.code, and the resulting quantity
|
267
|
+
%i[+ -].each do |operation|
|
268
|
+
define_method(operation) do |rvalue|
|
269
|
+
assert_commodity rvalue
|
270
|
+
|
271
|
+
lquantity, rquantity, dprecision = quantities_denominated_against rvalue
|
272
|
+
|
273
|
+
result = lquantity.send operation, rquantity
|
274
|
+
|
275
|
+
# Adjust the dprecision. Probably there's a better way to do this, but,
|
276
|
+
# this works
|
277
|
+
our_currency = RVGP::Journal::Currency.from_code_or_symbol code
|
278
|
+
|
279
|
+
# This is a special case:
|
280
|
+
return RVGP::Journal::Commodity.new code, alphabetic_code, result, our_currency.minor_unit if result.zero?
|
281
|
+
|
282
|
+
# If we're trying to remove more digits than minor_unit, we have to adjust
|
283
|
+
# our cut
|
284
|
+
if our_currency && (dprecision > our_currency.minor_unit) && /\A.+?(0+)\Z/.match(result.to_s) &&
|
285
|
+
::Regexp.last_match(1)
|
286
|
+
trim_length = ::Regexp.last_match(1).length
|
287
|
+
dprecision -= trim_length
|
288
|
+
|
289
|
+
if dprecision < our_currency.minor_unit
|
290
|
+
add = our_currency.minor_unit - dprecision
|
291
|
+
dprecision += add
|
292
|
+
trim_length -= add
|
293
|
+
end
|
294
|
+
|
295
|
+
result /= 10**trim_length
|
296
|
+
end
|
297
|
+
|
298
|
+
RVGP::Journal::Commodity.new code, alphabetic_code, result, dprecision
|
299
|
+
end
|
300
|
+
end
|
301
|
+
|
302
|
+
# We're mostly/only using this to support [].sum atm
|
303
|
+
def coerce(other)
|
304
|
+
super unless other.is_a? Integer
|
305
|
+
|
306
|
+
[RVGP::Journal::Commodity.new(code, alphabetic_code, other, precision), self]
|
307
|
+
end
|
308
|
+
|
309
|
+
def respond_to_missing?(name, _include_private = false)
|
310
|
+
@quantity.respond_to? name
|
311
|
+
end
|
312
|
+
|
313
|
+
# If an unhandled methods is encountered between ourselves, and another commodity,
|
314
|
+
# we dispatch that method to the quantity of self, against the quantity of the
|
315
|
+
# provided commodity.
|
316
|
+
# @overload method_missing(attr, rvalue)
|
317
|
+
# @param [Symbol] attr An unhandled method
|
318
|
+
# @param [RVGP::Journal::Commodity] rvalue The operand
|
319
|
+
# @return [RVGP::Journal::Commodity] A new Commodity object, created using our code, and the resulting quantity.
|
320
|
+
def method_missing(name, *args, &blk)
|
321
|
+
# This handles most all of the numeric methods
|
322
|
+
if @quantity.respond_to?(name) && args.length == 1 && args[0].is_a?(self.class)
|
323
|
+
assert_commodity args[0]
|
324
|
+
|
325
|
+
unless commodity.precision == precision
|
326
|
+
raise UnimplementedError, format('Unimplemented operation %s Wot do?', name.inspect)
|
327
|
+
end
|
328
|
+
|
329
|
+
RVGP::Journal::Commodity.new code, alphabetic_code, @quantity.send(name, args[0].quantity, &blk), precision
|
330
|
+
else
|
331
|
+
super
|
332
|
+
end
|
333
|
+
end
|
334
|
+
|
335
|
+
# Given a string, such as "$ 20.57", or "1 MERCEDESBENZ", Construct and return a commodity representation
|
336
|
+
# @param [String] str The commodity, as would be found in a PTA journal
|
337
|
+
# @return [RVGP::Journal::Commodity]
|
338
|
+
def self.from_s(str)
|
339
|
+
commodity_parts_from_string str
|
340
|
+
end
|
341
|
+
|
342
|
+
# @!visibility private
|
343
|
+
# This parses a commodity in the same way that from_s parses, but, returns the strin that remains after the
|
344
|
+
# commodity. Mostly this is here to keep ComplexCommodity DRY. Probably you shouldn't use this method
|
345
|
+
# @return [Array<RVGP::Journal::Commodity, String>] A two element array, containing a commodity, and the unparsed
|
346
|
+
# string, that remained after the commodity.
|
347
|
+
def self.from_s_with_remainder(str)
|
348
|
+
commodity_parts_from_string str, with_remainder: true
|
349
|
+
end
|
350
|
+
|
351
|
+
# Given a code, or symbol, and a quantity - Construct and return a commodity representation.
|
352
|
+
# @param [String] symbol The commodity code, or symbol, as would be found in a PTA journal
|
353
|
+
# @param [Integer, String] amount The commodity quantity. If this is a string, we search for periods, and
|
354
|
+
# calculate precision. If this is an int, we assume a precision based on
|
355
|
+
# the commodity code.
|
356
|
+
# @return [RVGP::Journal::Commodity]
|
357
|
+
def self.from_symbol_and_amount(symbol, amount = 0)
|
358
|
+
currency = RVGP::Journal::Currency.from_code_or_symbol symbol
|
359
|
+
precision, quantity = *precision_and_quantity_from_amount(amount)
|
360
|
+
# NOTE: Sometimes (say shares) we deal with fractions of a penny. If this
|
361
|
+
# is such a case, we preserve the larger precision
|
362
|
+
if currency && currency.minor_unit > precision
|
363
|
+
# This is a case where, say "$ 1" is passed. But, we want to store that
|
364
|
+
# as 100
|
365
|
+
quantity *= 10**(currency.minor_unit - precision)
|
366
|
+
precision = currency.minor_unit
|
367
|
+
end
|
368
|
+
|
369
|
+
new symbol, currency ? currency.alphabetic_code : symbol, quantity, precision
|
370
|
+
end
|
371
|
+
|
372
|
+
# @!visibility private
|
373
|
+
def self.commodity_parts_from_string(string, opts = {})
|
374
|
+
(opts[:with_remainder] ? MATCH_COMMODITY_WITH_REMAINDER : MATCH_COMMODITY_WITHOUT_REMAINDER).match string.to_s
|
375
|
+
|
376
|
+
code, amount = if ::Regexp.last_match(1) && !::Regexp.last_match(1).empty?
|
377
|
+
[::Regexp.last_match(1),
|
378
|
+
[::Regexp.last_match(2), ::Regexp.last_match(3)].compact.reject(&:empty?).first]
|
379
|
+
elsif ::Regexp.last_match(4) && !::Regexp.last_match(4).empty?
|
380
|
+
[[::Regexp.last_match(5),
|
381
|
+
::Regexp.last_match(6)].compact.reject(&:empty?).first, ::Regexp.last_match(4)]
|
382
|
+
elsif ::Regexp.last_match(2) && !::Regexp.last_match(2).empty?
|
383
|
+
[::Regexp.last_match(2),
|
384
|
+
[::Regexp.last_match(3), ::Regexp.last_match(4)].compact.reject(&:empty?).first]
|
385
|
+
end
|
386
|
+
|
387
|
+
if !amount || !code || amount.empty? || code.empty?
|
388
|
+
raise UnimplementedError, format('Unimplemented Commodity::from_s. Against: %s. Wot do?', string.inspect)
|
389
|
+
end
|
390
|
+
|
391
|
+
commodity = from_symbol_and_amount code, amount.tr(',', '')
|
392
|
+
|
393
|
+
opts[:with_remainder] ? [commodity, ::Regexp.last_match(7)] : commodity
|
394
|
+
end
|
395
|
+
|
396
|
+
# @!visibility private
|
397
|
+
def self.precision_and_quantity_from_amount(amount)
|
398
|
+
[amount.to_s.reverse.index('.') || 0, amount.to_s.tr('.', '').to_i]
|
399
|
+
end
|
400
|
+
|
401
|
+
private
|
402
|
+
|
403
|
+
def round_or_floor(to_digit, function)
|
404
|
+
raise UnimplementedError unless to_digit >= 0
|
405
|
+
|
406
|
+
characteristic, mantissa = quantity_as_decimal_pair
|
407
|
+
|
408
|
+
new_characteristic = characteristic * (10**to_digit)
|
409
|
+
new_mantissa = mantissa.positive? ? (mantissa / (10**(precision - to_digit))).to_i : 0
|
410
|
+
|
411
|
+
new_quantity = new_characteristic + new_mantissa
|
412
|
+
|
413
|
+
# Round up?
|
414
|
+
if function == :round && mantissa.positive? && precision > to_digit
|
415
|
+
# We want the determinant to be the right-most digit in the round_determinant, then we mod 10 that
|
416
|
+
round_determinant = (mantissa / (10**(precision - to_digit - 1)) % 10).to_i
|
417
|
+
|
418
|
+
new_quantity += 1 if round_determinant >= 5
|
419
|
+
end
|
420
|
+
|
421
|
+
RVGP::Journal::Commodity.new code, alphabetic_code, positive? ? new_quantity : new_quantity * -1, to_digit
|
422
|
+
end
|
423
|
+
|
424
|
+
# This returns our quantity, and rvalue.quantity, after adjusting both
|
425
|
+
# to the largest of the two denominators
|
426
|
+
def quantities_denominated_against(rvalue)
|
427
|
+
lquantity = quantity
|
428
|
+
rquantity = rvalue.quantity
|
429
|
+
new_precision = precision
|
430
|
+
|
431
|
+
if precision > rvalue.precision
|
432
|
+
new_precision = precision
|
433
|
+
rquantity = rvalue.quantity * (10**(precision - rvalue.precision))
|
434
|
+
elsif precision < rvalue.precision
|
435
|
+
new_precision = rvalue.precision
|
436
|
+
lquantity = quantity * (10**(rvalue.precision - precision))
|
437
|
+
end
|
438
|
+
|
439
|
+
[lquantity, rquantity, new_precision]
|
440
|
+
end
|
441
|
+
|
442
|
+
def assert_commodity(commodity)
|
443
|
+
our_codes = [alphabetic_code, code]
|
444
|
+
|
445
|
+
unless our_codes.include?(commodity.alphabetic_code)
|
446
|
+
raise ConversionError, format('Provided commodity %<commodity>s does not match %<codes>s',
|
447
|
+
commodity: commodity.inspect,
|
448
|
+
codes: our_codes.inspect)
|
449
|
+
end
|
450
|
+
end
|
451
|
+
end
|
452
|
+
end
|
453
|
+
end
|
@@ -0,0 +1,214 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module RVGP
|
4
|
+
class Journal
|
5
|
+
# These 'complex currency' specifications appear to be mostly for non-register
|
6
|
+
# and non-balance reports. The ledger manual labels these 'Cost Expressions'.
|
7
|
+
# We really don't use these much, and I'm not entirely sure the parsing rules
|
8
|
+
# make sense. Some of the rules in the documentation even seem a bit
|
9
|
+
# inconsistent (compare complex expressions vs comments).
|
10
|
+
#
|
11
|
+
# Here's some examples of Complex Commodities:
|
12
|
+
# ```
|
13
|
+
# 10 AAPL @@ $500.00
|
14
|
+
# 10 AAPL @ ($500.00 / 10)
|
15
|
+
# (5 AAPL * 2) @ ($500.00 / 10)
|
16
|
+
# 1000 AAPL (@) $1
|
17
|
+
# -10 AAPL {{$500.00}} @@ $750.00
|
18
|
+
# 10 AAPL {=$50.00}
|
19
|
+
# -5 AAPL {$50.00} [2012-04-10] @@ $375.00
|
20
|
+
# -5 AAPL {$50.00} [2012-04-10] (Oh my!) @@ $375.00
|
21
|
+
# -5 AAPL {$50.00} ((ten_dollars)) @@ $375.00
|
22
|
+
# -5 AAPL {$50.00} ((s, d, t -> market($10, date, t))) @@ $375.00
|
23
|
+
# ```
|
24
|
+
#
|
25
|
+
# We ended up needing most of this class to run {RVGP::Validations::DuplicateTagsValidation}.
|
26
|
+
# And, to ensure that we're able to mostly-validate the syntax of the journals. We don't actually
|
27
|
+
# use many code paths here, otherwise. (Though we do use it to serialize currency conversion in
|
28
|
+
# the file shorthand/investment.rb)
|
29
|
+
#
|
30
|
+
# I'm not entirely sure what attribute names to use. We could go with intent
|
31
|
+
# position, or with class. Either path seems to introduce exceptions. Possibly
|
32
|
+
# some of these attributes should just go into the transfer class. I'm also not
|
33
|
+
# sure that the left_/right_/operation system makes sense.
|
34
|
+
#
|
35
|
+
# I also think we need some adjustments here to cover all parsing cases. But,
|
36
|
+
# for now this works well enough, again mostly because we're not using most
|
37
|
+
# of these code paths... Lets see if/how this evolves.
|
38
|
+
#
|
39
|
+
# @attr_reader [RVGP::Commodity::Journal] left The 'left' component of the complex commodity
|
40
|
+
# @attr_reader [RVGP::Commodity::Journal] right The 'right' component of the complex commodity
|
41
|
+
# @attr_reader [Symbol] operation The 'operation' component of the complex commodity, either :right_expression,
|
42
|
+
# :left_expression, :per_unit, or :per_lot
|
43
|
+
# @attr_reader [Date] left_date The 'left_date' component of the complex commodity
|
44
|
+
# @attr_reader [String] left_lot The 'left_lot' component of the complex commodity
|
45
|
+
# @attr_reader [Symbol] left_lot_operation The 'left_lot_operation' component of the complex commodity, either
|
46
|
+
# :per_unit, or :per_lot
|
47
|
+
# @attr_reader [TrueClass, FalseClass] left_lot_is_equal The 'left_lot_is_equal' component of the complex
|
48
|
+
# commodity
|
49
|
+
# @attr_reader [String] left_expression The 'left_expression' component of the complex commodity
|
50
|
+
# @attr_reader [String] right_expression The 'right_expression' component of the complex commodity
|
51
|
+
# @attr_reader [String] left_lambda The 'left_lambda' component of the complex commodity
|
52
|
+
# @attr_reader [TrueClass, FalseClass] left_is_equal The 'left_is_equal' component of the complex commodity
|
53
|
+
# @attr_reader [TrueClass, FalseClass] right_is_equal The 'right_is_equal' component of the complex commodity
|
54
|
+
class ComplexCommodity
|
55
|
+
# @!visibility private
|
56
|
+
LOT_MATCH = /\A(\{+) *(=?) *([^}]+)\}+(.*)\Z/.freeze
|
57
|
+
# @!visibility private
|
58
|
+
LAMBDA_MATCH = /\A\(\((.+)\)\)(.*)\Z/.freeze
|
59
|
+
# @!visibility private
|
60
|
+
OP_MATCH = /\A(@{1,2})(.*)\Z/.freeze
|
61
|
+
# @!visibility private
|
62
|
+
WHITESPACE_MATCH = /\A[ \t]+(.*)\Z/.freeze
|
63
|
+
# @!visibility private
|
64
|
+
EQUAL_MATCH = /\A=(.*)\Z/.freeze
|
65
|
+
|
66
|
+
# @!visibility private
|
67
|
+
DATE_MATCH = /\A\[(\d{4})-(\d{1,2})-(\d{1,2})\](.*)\Z/.freeze
|
68
|
+
# @!visibility private
|
69
|
+
COMMENT_MATCH = /\A\(([^)]+)\)(.*)\Z/.freeze
|
70
|
+
# @!visibility private
|
71
|
+
MSG_TOO_MANY = 'Too many %s in ComplexCommodity::from_s. Against: %s'
|
72
|
+
# @!visibility private
|
73
|
+
MSG_UNPARSEABLE = 'The ComplexCommodity::from_s "%s" appears to be unparseable'
|
74
|
+
|
75
|
+
# @!visibility private
|
76
|
+
ATTRIBUTES = %i[left right operation left_date left_lot left_lot_operation left_lot_is_equal left_expression
|
77
|
+
right_expression left_lambda left_is_equal right_is_equal].freeze
|
78
|
+
|
79
|
+
# This is written this way, to prevent yard from triggering an Undocumentable ATTRIBUTES warning.
|
80
|
+
send(:attr_reader, *ATTRIBUTES)
|
81
|
+
|
82
|
+
# Raised on a parse error
|
83
|
+
class Error < StandardError; end
|
84
|
+
|
85
|
+
# Create a complex commodity, from constituent parts
|
86
|
+
# @param [Hash] opts The parts of this complex commodity
|
87
|
+
# @option opts [String] code see {Commodity#code}
|
88
|
+
# @option opts [RVGP::Journal::Commodity] left see {ComplexCommodity#left}
|
89
|
+
# @option opts [RVGP::Journal::Commodity] right see {ComplexCommodity#right}
|
90
|
+
# @option opts [Symbol] operation see {ComplexCommodity#operation}
|
91
|
+
# @option opts [Date] left_date see {ComplexCommodity#left_date}
|
92
|
+
# @option opts [String] left_lot see {ComplexCommodity#left_lot}
|
93
|
+
# @option opts [Symbol] left_lot_operation see {ComplexCommodity#left_lot_operation}
|
94
|
+
# @option opts [TrueClass, FalseClass] left_lot_is_equal see {ComplexCommodity#left_lot_is_equal}
|
95
|
+
# @option opts [String] left_expression see {ComplexCommodity#left_expression}
|
96
|
+
# @option opts [String] right_expression see {ComplexCommodity#right_expression}
|
97
|
+
# @option opts [String] left_lambda see {ComplexCommodity#left_lambda}
|
98
|
+
# @option opts [TrueClass, FalseClass] left_is_equal see {ComplexCommodity#left_is_equal}
|
99
|
+
# @option opts [TrueClass, FalseClass] right_is_equal see {ComplexCommodity#right_is_equal}
|
100
|
+
def initialize(opts = {})
|
101
|
+
ATTRIBUTES.select { |attr| opts.key? attr }.each do |attr|
|
102
|
+
instance_variable_set("@#{attr}".to_sym, opts[attr])
|
103
|
+
end
|
104
|
+
end
|
105
|
+
|
106
|
+
# For now, we simply delegate these messages to the :left commodity. This path
|
107
|
+
# is only in use in the reconciler, which, determines whether the commodity
|
108
|
+
# is income or expense. (and/or inverts the amount) It's conceivable that we
|
109
|
+
# may want to test the right commodity at some point here, and determine if
|
110
|
+
# the net operation is positive or negative.
|
111
|
+
def positive?
|
112
|
+
left.positive?
|
113
|
+
end
|
114
|
+
|
115
|
+
# For now, we simply delegate this message to the :left commodity.
|
116
|
+
def invert!
|
117
|
+
left.invert!
|
118
|
+
self
|
119
|
+
end
|
120
|
+
|
121
|
+
# De-parse this ComplexCommodity, back into its string representation
|
122
|
+
# @return [String]
|
123
|
+
def to_s
|
124
|
+
[left_is_equal ? '=' : nil,
|
125
|
+
left ? left.to_s : nil,
|
126
|
+
if left_lot_operation && left_lot
|
127
|
+
format(left_lot_operation == :per_unit ? '{%s}' : '{{%s}}',
|
128
|
+
*[left_lot_is_equal ? '=' : nil, left_lot.to_s].compact.join)
|
129
|
+
end,
|
130
|
+
left_date ? format('[%s]', left_date.to_s) : nil,
|
131
|
+
left_expression ? format('(%s)', left_expression.to_s) : nil,
|
132
|
+
left_lambda ? format('((%s))', left_lambda.to_s) : nil,
|
133
|
+
if operation
|
134
|
+
operation == :per_unit ? '@' : '@@'
|
135
|
+
end,
|
136
|
+
right_is_equal ? '=' : nil,
|
137
|
+
right ? right.to_s : nil,
|
138
|
+
right_expression ? format('(%s)', right_expression.to_s) : nil].compact.join(' ')
|
139
|
+
end
|
140
|
+
|
141
|
+
# Given a string, in one of the supported formats, construct and return a commodity representation.
|
142
|
+
# @param [String] string The commodity, as would be found in a PTA journal
|
143
|
+
# @return [RVGP::Journal::ComplexCommodity]
|
144
|
+
def self.from_s(string)
|
145
|
+
tmp = string.dup
|
146
|
+
opts = {}
|
147
|
+
|
148
|
+
# We treat string like a kind of stack, and we pop off what we can parse
|
149
|
+
# from the left, heading to the right
|
150
|
+
until tmp.empty?
|
151
|
+
case tmp
|
152
|
+
when WHITESPACE_MATCH
|
153
|
+
tmp = ::Regexp.last_match(1)
|
154
|
+
when EQUAL_MATCH
|
155
|
+
side = opts[:operation] ? :right_is_equal : :left_is_equal
|
156
|
+
ensure_not_too_many! opts, side, string
|
157
|
+
opts[side] = true
|
158
|
+
tmp = ::Regexp.last_match(1)
|
159
|
+
when LOT_MATCH
|
160
|
+
ensure_not_too_many! opts, :left_lot, string
|
161
|
+
opts[:left_lot_operation] = to_operator ::Regexp.last_match(1)
|
162
|
+
opts[:left_lot_is_equal] = (::Regexp.last_match(2) == '=')
|
163
|
+
opts[:left_lot] = ::Regexp.last_match(3).to_commodity
|
164
|
+
tmp = ::Regexp.last_match(4)
|
165
|
+
when LAMBDA_MATCH
|
166
|
+
ensure_not_too_many! opts, :left_lambda, string
|
167
|
+
opts[:left_lambda] = ::Regexp.last_match(1)
|
168
|
+
tmp = ::Regexp.last_match(2)
|
169
|
+
when DATE_MATCH
|
170
|
+
ensure_not_too_many! opts, :left_date, string
|
171
|
+
opts[:left_date] = Date.new(*(1..3).map { |i| ::Regexp.last_match(i).to_i })
|
172
|
+
tmp = ::Regexp.last_match(4)
|
173
|
+
when OP_MATCH
|
174
|
+
ensure_not_too_many! opts, :operation, string
|
175
|
+
opts[:operation] = to_operator ::Regexp.last_match(1)
|
176
|
+
tmp = ::Regexp.last_match(2)
|
177
|
+
when COMMENT_MATCH
|
178
|
+
side = opts[:operation] ? :right_expression : :left_expression
|
179
|
+
ensure_not_too_many! opts, side, string
|
180
|
+
opts[side] = ::Regexp.last_match(1)
|
181
|
+
tmp = ::Regexp.last_match(2)
|
182
|
+
else
|
183
|
+
begin
|
184
|
+
commodity, tmp = RVGP::Journal::Commodity.from_s_with_remainder tmp
|
185
|
+
rescue RVGP::Journal::Commodity::Error
|
186
|
+
raise Error, MSG_UNPARSEABLE % string
|
187
|
+
end
|
188
|
+
|
189
|
+
side = opts[:operation] ? :right : :left
|
190
|
+
ensure_not_too_many! opts, side, string
|
191
|
+
opts[side] = commodity
|
192
|
+
end
|
193
|
+
end
|
194
|
+
|
195
|
+
new opts
|
196
|
+
end
|
197
|
+
|
198
|
+
# @!visibility private
|
199
|
+
def self.to_operator(from_s)
|
200
|
+
case from_s
|
201
|
+
when /\A[@{]\Z/ then :per_unit
|
202
|
+
when /\A(?:@@|{{)\Z/ then :per_lot
|
203
|
+
else
|
204
|
+
raise Error, format('Unrecognized operator %s', from_s.inspect)
|
205
|
+
end
|
206
|
+
end
|
207
|
+
|
208
|
+
# This mostly just saves us some typing above in the from_s()
|
209
|
+
def self.ensure_not_too_many!(opts, key, string)
|
210
|
+
raise Error, format(MSG_TOO_MANY, key.to_s, string) if opts.key? key
|
211
|
+
end
|
212
|
+
end
|
213
|
+
end
|
214
|
+
end
|