rvgp 0.3.2

Sign up to get free protection for your applications and to get access to all the features.
Files changed (97) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +8 -0
  3. data/.rubocop.yml +23 -0
  4. data/LICENSE +504 -0
  5. data/README.md +223 -0
  6. data/Rakefile +32 -0
  7. data/bin/rvgp +8 -0
  8. data/lib/rvgp/application/config.rb +159 -0
  9. data/lib/rvgp/application/descendant_registry.rb +122 -0
  10. data/lib/rvgp/application/status_output.rb +139 -0
  11. data/lib/rvgp/application.rb +170 -0
  12. data/lib/rvgp/base/command.rb +457 -0
  13. data/lib/rvgp/base/grid.rb +531 -0
  14. data/lib/rvgp/base/reader.rb +29 -0
  15. data/lib/rvgp/base/reconciler.rb +434 -0
  16. data/lib/rvgp/base/validation.rb +261 -0
  17. data/lib/rvgp/commands/cashflow.rb +160 -0
  18. data/lib/rvgp/commands/grid.rb +70 -0
  19. data/lib/rvgp/commands/ireconcile.rb +95 -0
  20. data/lib/rvgp/commands/new_project.rb +296 -0
  21. data/lib/rvgp/commands/plot.rb +41 -0
  22. data/lib/rvgp/commands/publish_gsheets.rb +83 -0
  23. data/lib/rvgp/commands/reconcile.rb +58 -0
  24. data/lib/rvgp/commands/rotate_year.rb +202 -0
  25. data/lib/rvgp/commands/validate_journal.rb +59 -0
  26. data/lib/rvgp/commands/validate_system.rb +44 -0
  27. data/lib/rvgp/commands.rb +160 -0
  28. data/lib/rvgp/dashboard.rb +252 -0
  29. data/lib/rvgp/fakers/fake_feed.rb +245 -0
  30. data/lib/rvgp/fakers/fake_journal.rb +57 -0
  31. data/lib/rvgp/fakers/fake_reconciler.rb +88 -0
  32. data/lib/rvgp/fakers/faker_helpers.rb +25 -0
  33. data/lib/rvgp/gem.rb +80 -0
  34. data/lib/rvgp/journal/commodity.rb +453 -0
  35. data/lib/rvgp/journal/complex_commodity.rb +214 -0
  36. data/lib/rvgp/journal/currency.rb +101 -0
  37. data/lib/rvgp/journal/journal.rb +141 -0
  38. data/lib/rvgp/journal/posting.rb +156 -0
  39. data/lib/rvgp/journal/pricer.rb +267 -0
  40. data/lib/rvgp/journal.rb +24 -0
  41. data/lib/rvgp/plot/gnuplot.rb +478 -0
  42. data/lib/rvgp/plot/google-drive/output_csv.rb +44 -0
  43. data/lib/rvgp/plot/google-drive/output_google_sheets.rb +434 -0
  44. data/lib/rvgp/plot/google-drive/sheet.rb +67 -0
  45. data/lib/rvgp/plot.rb +293 -0
  46. data/lib/rvgp/pta/hledger.rb +237 -0
  47. data/lib/rvgp/pta/ledger.rb +308 -0
  48. data/lib/rvgp/pta.rb +311 -0
  49. data/lib/rvgp/reconcilers/csv_reconciler.rb +424 -0
  50. data/lib/rvgp/reconcilers/journal_reconciler.rb +41 -0
  51. data/lib/rvgp/reconcilers/shorthand/finance_gem_hacks.rb +48 -0
  52. data/lib/rvgp/reconcilers/shorthand/international_atm.rb +152 -0
  53. data/lib/rvgp/reconcilers/shorthand/investment.rb +144 -0
  54. data/lib/rvgp/reconcilers/shorthand/mortgage.rb +195 -0
  55. data/lib/rvgp/utilities/grid_query.rb +190 -0
  56. data/lib/rvgp/utilities/yaml.rb +131 -0
  57. data/lib/rvgp/utilities.rb +44 -0
  58. data/lib/rvgp/validations/balance_validation.rb +68 -0
  59. data/lib/rvgp/validations/duplicate_tags_validation.rb +48 -0
  60. data/lib/rvgp/validations/uncategorized_validation.rb +15 -0
  61. data/lib/rvgp.rb +66 -0
  62. data/resources/README.MD/2022-cashflow-google.png +0 -0
  63. data/resources/README.MD/2022-cashflow.png +0 -0
  64. data/resources/README.MD/all-wealth-growth-google.png +0 -0
  65. data/resources/README.MD/all-wealth-growth.png +0 -0
  66. data/resources/gnuplot/default.yml +80 -0
  67. data/resources/i18n/en.yml +192 -0
  68. data/resources/iso-4217-currencies.json +171 -0
  69. data/resources/skel/Rakefile +5 -0
  70. data/resources/skel/app/grids/cashflow_grid.rb +27 -0
  71. data/resources/skel/app/grids/monthly_income_and_expenses_grid.rb +25 -0
  72. data/resources/skel/app/grids/wealth_growth_grid.rb +35 -0
  73. data/resources/skel/app/plots/cashflow.yml +33 -0
  74. data/resources/skel/app/plots/monthly-income-and-expenses.yml +17 -0
  75. data/resources/skel/app/plots/wealth-growth.yml +20 -0
  76. data/resources/skel/config/csv-format-acme-checking.yml +9 -0
  77. data/resources/skel/config/google-secrets.yml +5 -0
  78. data/resources/skel/config/rvgp.yml +0 -0
  79. data/resources/skel/journals/prices.db +0 -0
  80. data/rvgp.gemspec +6 -0
  81. data/test/assets/ledger_total_monthly_liabilities_with_empty.xml +383 -0
  82. data/test/assets/ledger_total_monthly_liabilities_with_empty2.xml +428 -0
  83. data/test/test_command_base.rb +61 -0
  84. data/test/test_commodity.rb +270 -0
  85. data/test/test_csv_reconciler.rb +60 -0
  86. data/test/test_currency.rb +24 -0
  87. data/test/test_fake_feed.rb +228 -0
  88. data/test/test_fake_journal.rb +98 -0
  89. data/test/test_fake_reconciler.rb +60 -0
  90. data/test/test_journal_parse.rb +545 -0
  91. data/test/test_ledger.rb +102 -0
  92. data/test/test_plot.rb +133 -0
  93. data/test/test_posting.rb +50 -0
  94. data/test/test_pricer.rb +139 -0
  95. data/test/test_pta_adapter.rb +575 -0
  96. data/test/test_utilities.rb +45 -0
  97. 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