diff --git a/.gitignore b/.gitignore index 58709a5..10933b3 100644 --- a/.gitignore +++ b/.gitignore @@ -17,3 +17,6 @@ tmp test.rb spec/books/*.gnucash.*.* spec/books/*.LCK +.DS_Store +.vscode/ +bin/ diff --git a/Gemfile b/Gemfile index 24a260b..3c805c6 100644 --- a/Gemfile +++ b/Gemfile @@ -2,3 +2,8 @@ source 'https://rubygems.org' # Specify your gem's dependencies in gnucash.gemspec gemspec + +group :development do + # rdbg / Cursor: depuración (vscode-rdbg) + gem "debug", ">= 1.9", require: false +end diff --git a/Gemfile.lock b/Gemfile.lock index 416de0f..17216d6 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,16 +1,25 @@ PATH remote: . specs: - gnucash (1.5.0) + gnucash (1.6.0) nokogiri GEM remote: https://rubygems.org/ specs: date (3.5.1) + debug (1.11.1) + irb (~> 1.10) + reline (>= 0.3.8) diff-lcs (1.6.2) docile (1.4.1) erb (6.0.2) + io-console (0.8.2) + irb (1.17.0) + pp (>= 0.6.0) + prism (>= 1.3.0) + rdoc (>= 4.0.0) + reline (>= 0.4.2) mini_portile2 (2.8.9) nokogiri (1.19.1) mini_portile2 (~> 2.8.2) @@ -31,6 +40,10 @@ GEM racc (~> 1.4) nokogiri (1.19.1-x86_64-linux-musl) racc (~> 1.4) + pp (0.6.3) + prettyprint + prettyprint (0.2.0) + prism (1.9.0) psych (5.3.1) date stringio @@ -40,6 +53,8 @@ GEM erb psych (>= 4.0.0) tsort + reline (0.6.3) + io-console (~> 0.5) rspec (3.13.2) rspec-core (~> 3.13.0) rspec-expectations (~> 3.13.0) @@ -75,6 +90,7 @@ PLATFORMS x86_64-linux-musl DEPENDENCIES + debug (>= 1.9) gnucash! rake rdoc diff --git a/gnucash.gemspec b/gnucash.gemspec index ca2126c..a3e70a1 100644 --- a/gnucash.gemspec +++ b/gnucash.gemspec @@ -13,7 +13,7 @@ Gem::Specification.new do |gem| gem.homepage = "https://github.com/holtrop/ruby-gnucash" gem.license = "MIT" - gem.files = `git ls-files`.split($/) + gem.files = `git ls-files`.split($/).reject { |f| f == "bin/rdbg" } gem.executables = gem.files.grep(%r{^bin/}).map{ |f| File.basename(f) } gem.test_files = gem.files.grep(%r{^(test|spec|features)/}) gem.require_paths = ["lib"] diff --git a/lib/gnucash.rb b/lib/gnucash.rb index fd34d8e..dbee7a1 100644 --- a/lib/gnucash.rb +++ b/lib/gnucash.rb @@ -2,6 +2,8 @@ require_relative "gnucash/account" require_relative "gnucash/customer" require_relative "gnucash/account_transaction" +require_relative "gnucash/isin" +require_relative "gnucash/security" require_relative "gnucash/book" require_relative "gnucash/transaction" require_relative "gnucash/value" diff --git a/lib/gnucash/account.rb b/lib/gnucash/account.rb index 9fc3691..d5c7365 100644 --- a/lib/gnucash/account.rb +++ b/lib/gnucash/account.rb @@ -15,6 +15,11 @@ class Account # @return [String] The GUID of the account. attr_reader :id + # @return [String, nil] Account code (+act:code+), if set in GnuCash. + # + # @since 1.6.0 + attr_reader :code + # @return [Array] # List of transactions associated with this account. attr_reader :transactions @@ -22,6 +27,16 @@ class Account # @return [Boolean] Whether the account is a placeholder or not. attr_reader :placeholder + # @return [String, nil] Commodity namespace (+cmdty:space+) from +act:commodity+, if present. + # + # @since 1.6.0 + attr_reader :commodity_space + + # @return [String, nil] Commodity id (+cmdty:id+) from +act:commodity+, if present. + # + # @since 1.6.0 + attr_reader :commodity_id + # @since 1.4.0 # # @return [String, nil] The GUID of the parent account, if any. @@ -38,6 +53,8 @@ def initialize(book, node) @type = node.xpath('act:type').text @description = node.xpath('act:description').text @id = node.xpath('act:id').text + code_raw = node.at_xpath('act:code')&.text + @code = (code_raw.nil? || code_raw.empty?) ? nil : code_raw @parent_id = node.xpath('act:parent').text @parent_id = nil if @parent_id == "" @transactions = [] @@ -46,6 +63,29 @@ def initialize(book, node) (slot.xpath("slot:key").first.text == "placeholder" and slot.xpath("slot:value").first.text == "true") end ? true : false + + cmd = node.at_xpath("act:commodity") + if cmd + @commodity_space = cmd.at_xpath("cmdty:space")&.text&.strip + @commodity_id = cmd.at_xpath("cmdty:id")&.text&.strip + @commodity_space = nil if @commodity_space.nil? || @commodity_space.empty? + @commodity_id = nil if @commodity_id.nil? || @commodity_id.empty? + else + @commodity_space = nil + @commodity_id = nil + end + end + + # Priced {Security} for this account's commodity, if the commodity appears in the + # book's price database (+gnc:pricedb+). Otherwise +nil+. + # + # @since 1.6.0 + # + # @return [Security, nil] + def security + return nil unless @commodity_space && @commodity_id + + @book.find_security(@commodity_space, @commodity_id) end # Return the fully qualified account name. @@ -141,7 +181,7 @@ def balance_on(date, options = {}) # @return [Array] Attributes used to build the inspection string # @see Gnucash::Support::LightInspect def attributes - %i[id name description type placeholder parent_id] + %i[id name description type code placeholder parent_id commodity_space commodity_id] end private diff --git a/lib/gnucash/book.rb b/lib/gnucash/book.rb index 5508b13..bf1c91b 100644 --- a/lib/gnucash/book.rb +++ b/lib/gnucash/book.rb @@ -1,3 +1,4 @@ +require "date" require "zlib" require "nokogiri" @@ -6,6 +7,11 @@ module Gnucash class Book include Support::LightInspect + # One row from +gnc:pricedb+. + # + # @since 1.6.0 + PriceRow = Struct.new(:commodity_space, :commodity_id, :currency_space, :currency_id, :date, :value) + # @return [Array] Accounts in the book. attr_reader :accounts @@ -16,6 +22,12 @@ class Book # @return [Array] Transactions in the book. attr_reader :transactions + # @return [Array] + # Raw price-database rows (commodity/currency/value/date). Prefer + # {Security#value_on} for valuations. + # @since 1.6.0 + attr_reader :price_rows + # @return [Date] Date of the first transaction in the book. attr_reader :start_date @@ -43,6 +55,8 @@ def initialize(fname) build_customers build_accounts build_transactions + build_price_quotes + build_commodity_isin_index finalize end @@ -79,6 +93,63 @@ def find_customer_by_full_name(full_name) @customers.find { |a| a.full_name == full_name } end + # Return every {Security} that appears in the price database (unique commodity). + # + # @since 1.6.0 + # + # @return [Array] + def securities + @securities ||= @price_rows.map { |r| [r.commodity_space, r.commodity_id] }.uniq.map do |space, id| + Security.new(self, space, id) + end + end + + # Look up a security by GnuCash commodity +space+ and +id+. + # + # @since 1.6.0 + # + # @return [Security, nil] + def find_security(space, id) + return nil unless @price_rows.any? { |r| r.commodity_space == space && r.commodity_id == id } + Security.new(self, space, id) + end + + # Look up a priced security whose commodity defines this ISIN (+cmdty:xcode+ or a slot + # whose key matches +isin+, e.g. +user:ISIN+). Comparison ignores spaces, hyphens and case. + # + # @since 1.6.0 + # + # @param isin [String] ISIN as stored or typed (e.g. +"US0378331005"+). + # + # @return [Security, nil] + def find_security_by_isin(isin) + key = ISIN.normalize(isin) + return nil if key.empty? + + pair = @isin_index[key] + return nil unless pair + + find_security(pair[0], pair[1]) + end + + # ISIN for a commodity if present in the book (+nil+ otherwise). + # + # @since 1.6.0 + # + # @return [String, nil] Normalized ISIN (12 uppercase alphanumeric characters). + def isin_for_commodity(space, id) + @isin_for_commodity[[space, id]] + end + + # Price-database rows for one commodity (used by {Security#value_on}). + # + # @since 1.6.0 + # + # @return [Array] + def quotes_for_commodity(space, id) + @price_rows.select { |r| r.commodity_space == space && r.commodity_id == id } + end + # Attributes available for inspection # # @return [Array] Attributes used to build the inspection string @@ -114,6 +185,82 @@ def build_transactions end end + # @return [void] + def build_price_quotes + pricedb = @book_node.at_xpath('gnc:pricedb') + @price_rows = [] + return unless pricedb + + pricedb.element_children.each do |node| + next unless node.element? + row = parse_price_node(node) + @price_rows << row if row + end + end + + # @return [PriceRow, nil] + def parse_price_node(node) + return nil unless node.at_xpath('price:commodity') + + cmd = node.at_xpath('price:commodity') + cur = node.at_xpath('price:currency') + return nil unless cmd && cur + + commodity_space = cmd.at_xpath('cmdty:space')&.text + commodity_id = cmd.at_xpath('cmdty:id')&.text + currency_space = cur.at_xpath('cmdty:space')&.text + currency_id = cur.at_xpath('cmdty:id')&.text + return nil if [commodity_space, commodity_id, currency_space, currency_id].any? { |s| s.nil? || s.empty? } + + ts = node.at_xpath('price:time/ts:date')&.text + return nil if ts.nil? || ts.empty? + + date = Date.parse(ts.split(' ').first) + val_text = node.at_xpath('price:value')&.text + return nil if val_text.nil? || val_text.empty? + + PriceRow.new(commodity_space, commodity_id, currency_space, currency_id, date, Value.new(val_text)) + end + + # @return [void] + def build_commodity_isin_index + @isin_for_commodity = {} + @isin_index = {} + + @book_node.xpath("gnc:commodity").each do |node| + space = node.at_xpath("cmdty:space")&.text&.strip + id = node.at_xpath("cmdty:id")&.text&.strip + next if space.nil? || id.nil? || space.empty? || id.empty? + + raw = extract_isin_from_commodity_node(node) + next unless raw + + key = ISIN.normalize(raw) + next unless ISIN.valid_format?(key) + + @isin_for_commodity[[space, id]] = key + @isin_index[key] ||= [space, id] + end + end + + # @return [String, nil] raw ISIN string from XML before normalization + def extract_isin_from_commodity_node(node) + node.xpath(".//slot").each do |slot| + k = slot.at_xpath("slot:key")&.text + next unless k&.match?(/isin/i) + + v = slot.at_xpath("slot:value")&.text&.strip + next if v.nil? || v.empty? + + return v if ISIN.valid_format?(v) + end + + xcode = node.at_xpath("cmdty:xcode")&.text&.strip + return xcode if xcode && ISIN.valid_format?(xcode) + + nil + end + # @return [void] def finalize @accounts.sort! do |a, b| diff --git a/lib/gnucash/isin.rb b/lib/gnucash/isin.rb new file mode 100644 index 0000000..b03c5c4 --- /dev/null +++ b/lib/gnucash/isin.rb @@ -0,0 +1,25 @@ +module Gnucash + # Helpers for ISIN (ISO 6166) strings as stored on GnuCash commodities. + # + # @since 1.6.0 + module ISIN + module_function + + # @param str [String, nil] + # @return [String] Uppercase ISIN with spaces and hyphens removed. + def normalize(str) + return "" if str.nil? || str.to_s.empty? + str.to_s.gsub(/[\s-]/, "").upcase + end + + # Rough format check: 12 alphanumeric characters after normalization. + # + # @param str [String, nil] + # @return [Boolean] + def valid_format?(str) + s = normalize(str) + return false if s.length != 12 + s.match?(/\A[A-Z0-9]{12}\z/) + end + end +end diff --git a/lib/gnucash/security.rb b/lib/gnucash/security.rb new file mode 100644 index 0000000..53397fd --- /dev/null +++ b/lib/gnucash/security.rb @@ -0,0 +1,114 @@ +require "date" + +module Gnucash + # Price quote for a security (commodity) from the GnuCash price database: the + # value of one unit of the security expressed in +currency+ as of +date+. + # + # @since 1.6.0 + class SecurityQuote + include Support::LightInspect + + # @return [Value] Price of one unit of the security in the quote currency. + attr_reader :value + + # @return [String] Commodity namespace (GnuCash "space") of the quote currency. + attr_reader :currency_space + + # @return [String] Commodity id of the quote currency (e.g. +"USD"+). + attr_reader :currency_id + + # @return [Date] Date of the price in the book (quote time, date-only). + attr_reader :date + + def initialize(value:, currency_space:, currency_id:, date:) + @value = value + @currency_space = currency_space + @currency_id = currency_id + @date = date + end + + def attributes + %i[value currency_space currency_id date] + end + end + + # A security (stock, fund, etc.) identified by its GnuCash commodity + # +space+ and +id+, with prices loaded from the book's price database. + # + # @since 1.6.0 + class Security + include Support::LightInspect + + # @return [String] Commodity namespace (e.g. +"NASDAQ"+, +"ISO4217"+). + attr_reader :space + + # @return [String] Commodity id (e.g. ticker or currency code). + attr_reader :id + + # @param book [Book] Parent book. + # @param space [String] Commodity space. + # @param id [String] Commodity id. + def initialize(book, space, id) + @book = book + @space = space + @id = id + end + + # @return [String, nil] ISIN from the commodity definition (+cmdty:xcode+ or ISIN slot), normalized. + def isin + @book.isin_for_commodity(@space, @id) + end + + # Return the price quote whose date is closest to the given valuation date. + # Distance is measured in calendar days; if two quotes are equally close, the + # earlier quote is used. + # + # If the security has multiple quote currencies, +currency_space+ and + # +currency_id+ select one; if omitted, USD (+ISO4217+ / +USD+) is preferred + # when present, otherwise an arbitrary quote chain is used. + # + # @param date [String, Date] Valuation date. + # @param currency_space [String, nil] Restrict to this quote currency space. + # @param currency_id [String, nil] Restrict to this quote currency id. + # + # @return [SecurityQuote, nil] Quote used for valuation, or nil if none applies. + def value_on(date, currency_space: nil, currency_id: nil) + date = Date.parse(date) if date.is_a?(String) + if (currency_space.nil? ^ currency_id.nil?) + raise ArgumentError, "currency_space and currency_id must both be set or both omitted" + end + + quotes = @book.quotes_for_commodity(@space, @id) + return nil if quotes.empty? + + filtered = + if currency_space + quotes.select { |q| q.currency_space == currency_space && q.currency_id == currency_id } + else + quotes + end + return nil if filtered.empty? + + pick_currency = lambda do |list| + usd = list.select { |q| q.currency_space == "ISO4217" && q.currency_id == "USD" } + (usd.empty? ? list : usd) + end + + candidates = currency_space ? filtered : pick_currency.call(filtered) + return nil if candidates.empty? + + best = candidates.min_by { |q| [(q.date - date).abs, q.date] } + + SecurityQuote.new( + value: best.value, + currency_space: best.currency_space, + currency_id: best.currency_id, + date: best.date + ) + end + + def attributes + %i[space id isin] + end + end +end diff --git a/lib/gnucash/version.rb b/lib/gnucash/version.rb index e417205..3f6fda8 100644 --- a/lib/gnucash/version.rb +++ b/lib/gnucash/version.rb @@ -1,4 +1,4 @@ module Gnucash # gem version - VERSION = "1.5.0" + VERSION = "1.6.0" end diff --git a/spec/books/pricedb-fixture.gnucash b/spec/books/pricedb-fixture.gnucash new file mode 100644 index 0000000..41ac99b Binary files /dev/null and b/spec/books/pricedb-fixture.gnucash differ diff --git a/spec/gnucash/account_spec.rb b/spec/gnucash/account_spec.rb index 01684b7..784383a 100644 --- a/spec/gnucash/account_spec.rb +++ b/spec/gnucash/account_spec.rb @@ -70,7 +70,36 @@ module Gnucash end it "avoid inspection of heavier attributes" do - expect(@salary.inspect).to eq "#" + expect(@salary.inspect).to eq "#" + end + + context "with pricedb-fixture (account linked to a priced security)" do + before(:all) do + @pricedb_book = Gnucash.open("spec/books/pricedb-fixture.gnucash") + @stocks_account = @pricedb_book.find_account_by_full_name("Stocks") + end + + it "returns the Security for the account commodity when it appears in the price database" do + sec = @stocks_account.security + expect(sec).not_to be_nil + expect(sec.space).to eq("TEST") + expect(sec.id).to eq("STK") + expect(sec.isin).to eq("US0378331005") + end + + it "returns nil when the account commodity has no priced security" do + brokerage = @pricedb_book.find_account_by_full_name("Brokerage") + expect(brokerage.security).to be_nil + end + + it "exposes the GnuCash account code (act:code)" do + expect(@pricedb_book.find_account_by_full_name("Brokerage").code).to eq("98234989234") + expect(@stocks_account.code).to eq("9823498n ewori oio982394") + end + + it "returns nil for code when act:code is absent" do + expect(@pricedb_book.find_account_by_full_name("Root Account").code).to be_nil + end end end end diff --git a/spec/gnucash/isin_spec.rb b/spec/gnucash/isin_spec.rb new file mode 100644 index 0000000..db6b125 --- /dev/null +++ b/spec/gnucash/isin_spec.rb @@ -0,0 +1,19 @@ +module Gnucash + describe ISIN do + describe ".normalize" do + it "strips spaces and hyphens and uppercases" do + expect(ISIN.normalize("us 03783-31005")).to eq("US0378331005") + end + end + + describe ".valid_format?" do + it "accepts 12 alphanumeric characters" do + expect(ISIN.valid_format?("US0378331005")).to be true + end + + it "rejects wrong length" do + expect(ISIN.valid_format?("US037833100")).to be false + end + end + end +end diff --git a/spec/gnucash/security_spec.rb b/spec/gnucash/security_spec.rb new file mode 100644 index 0000000..0658284 --- /dev/null +++ b/spec/gnucash/security_spec.rb @@ -0,0 +1,87 @@ +module Gnucash + describe Security do + before(:all) do + @book = Gnucash.open("spec/books/pricedb-fixture.gnucash") + @security = @book.find_security("TEST", "STK") + end + + it "loads price rows from the fixture" do + expect(@book.price_rows.size).to eq(4) + end + + it "lists securities from the price database" do + expect(@book.securities.size).to eq(2) + ids = @book.securities.map(&:id).sort + expect(ids).to eq(%w[ESFUND STK]) + end + + it "returns nil when the commodity is not priced" do + expect(@book.find_security("NONE", "X")).to be_nil + end + + describe "ISIN" do + it "exposes isin from cmdty:xcode" do + expect(@security.isin).to eq("US0378331005") + end + + it "exposes isin from commodity slots" do + s = @book.find_security("TEST", "ESFUND") + expect(s.isin).to eq("ES0105046009") + end + + it "finds security by ISIN ignoring case and spaces" do + found = @book.find_security_by_isin("us 03783-31005") + expect(found.id).to eq("STK") + expect(@book.find_security_by_isin("ES0105046009").id).to eq("ESFUND") + end + + it "returns nil when ISIN is unknown or commodity not priced" do + expect(@book.find_security_by_isin("DE0000000000")).to be_nil + end + end + + it "supports light inspect on security and quote" do + expect(@security.inspect).to include("TEST", "STK", "US0378331005") + q = @security.value_on(Date.new(2020, 6, 1)) + expect(q.inspect).to include("currency_id", "USD") + end + + describe "#value_on" do + it "uses the closest quote by calendar distance (also before the first quote)" do + q = @security.value_on(Date.new(2019, 12, 31)) + expect(q.date).to eq(Date.new(2020, 1, 1)) + expect(q.value).to eq(Value.new("10000/100")) + end + + it "picks the nearest quote on either side of the date" do + q = @security.value_on(Date.new(2020, 3, 15)) + expect(q.value).to eq(Value.new("10000/100")) + expect(q.date).to eq(Date.new(2020, 1, 1)) + + q2 = @security.value_on(Date.new(2020, 6, 1)) + expect(q2.value).to eq(Value.new("15000/100")) + expect(q2.date).to eq(Date.new(2020, 6, 1)) + + q3 = @security.value_on("2020-12-31") + expect(q3.date).to eq(Date.new(2021, 1, 1)) + expect(q3.value).to eq(Value.new("20000/100")) + end + + it "accepts an explicit quote currency" do + q = @security.value_on( + Date.new(2020, 12, 31), + currency_space: "CURRENCY", + currency_id: "USD" + ) + expect(q.value.to_f).to eq(200.0) + expect(q.date).to eq(Date.new(2021, 1, 1)) + end + + it "raises when only one currency keyword is given" do + expect { + @security.value_on(Date.new(2020, 1, 1), currency_space: "CURRENCY") + }.to raise_error(ArgumentError) + end + end + end +end