gnucash stable: Multiple changes pushed
John Ralls
jralls at code.gnucash.org
Sat Jun 27 12:49:33 EDT 2026
Updated via https://github.com/Gnucash/gnucash/commit/1d744065 (commit)
via https://github.com/Gnucash/gnucash/commit/ec97ca60 (commit)
from https://github.com/Gnucash/gnucash/commit/b57349e8 (commit)
commit 1d744065f1a945a1cda698d38e2c77b3eccb5192
Merge: b57349e83d ec97ca6029
Author: John Ralls <jralls at ceridwen.us>
Date: Sat Jun 27 09:48:25 2026 -0700
Merge Brent McBride's 'Investment-lot-CAGR-columns' into stable.
commit ec97ca6029a0d451b3389456c743ab68e9f7b9da
Author: Brent McBride <mcbridebt at hotmail.com>
Date: Wed Jun 17 22:52:54 2026 -0700
Add CAGR columns to Investment Lots report
Add compound annual growth rate (CAGR) columns to the Investment Lots report, refactor the split-handling logic, and expand the test-investment-lots SRFI-64 suite to cover the new calculations and reverse-split regression cases.
diff --git a/gnucash/report/reports/standard/investment-lots.scm b/gnucash/report/reports/standard/investment-lots.scm
index 40a897fcd3..7d38425637 100644
--- a/gnucash/report/reports/standard/investment-lots.scm
+++ b/gnucash/report/reports/standard/investment-lots.scm
@@ -60,12 +60,14 @@
(define optname-show-sold-columns (N_ "Show sold columns"))
(define optname-show-end-columns (N_ "Show end columns"))
(define optname-show-realized-gain-columns
- (N_ "Show realized gain column(s)"))
+ (N_ "Show realized gain columns"))
(define optname-show-unrealized-gain-columns
- (N_ "Show unrealized gain column(s)"))
+ (N_ "Show unrealized gain columns"))
(define optname-group-gains-by-age
(N_ "Group gains by age (short term and long term)"))
(define optname-long-term-years (N_ "Long term gains age (years)"))
+(define optname-show-roi-columns (N_ "Show ROI columns"))
+(define optname-show-cagr-columns (N_ "Show CAGR columns"))
;; Display
(define optname-show-long-account-names (N_ "Show long account names"))
@@ -123,10 +125,12 @@
(define colname-short-term-realized-gain (N_ "ST Realized Gain"))
(define colname-long-term-realized-gain (N_ "LT Realized Gain"))
(define colname-realized-roi (N_ "Realized ROI"))
+(define colname-realized-cagr (N_ "Realized CAGR"))
(define colname-unrealized-gain (N_ "Unrealized Gain"))
(define colname-short-term-unrealized-gain (N_ "ST Unrealized Gain"))
(define colname-long-term-unrealized-gain (N_ "LT Unrealized Gain"))
(define colname-unrealized-roi (N_ "Unrealized ROI"))
+(define colname-unrealized-cagr (N_ "Unrealized CAGR"))
(define label-account-total (N_ "Account Lots Total"))
(define label-grand-total (N_ "Grand Total"))
@@ -135,7 +139,18 @@
;; will use to display a dialog where the user can select
;; values for the report's parameters.
(define (options-generator)
- (let* ((options (gnc-new-optiondb)))
+ (let ((options (gnc-new-optiondb)))
+
+ ;; When both realized and unrealized gain columns are hidden, the gain
+ ;; percentage columns (ROI and CAGR) won't be visible anyways, so disable
+ ;; their corresponding options.
+ (define (update-gain-percentage-columns-enabled enabled)
+ (for-each
+ (lambda (name)
+ (gnc-optiondb-set-option-selectable-by-name options
+ pagename-columns name enabled))
+ (list optname-show-roi-columns
+ optname-show-cagr-columns)))
;; Accounts tab
(gnc-register-account-list-limited-option options
@@ -207,7 +222,7 @@
(cons 'percent 50.0))
;; Columns tab
- (gnc-register-simple-boolean-option options
+ (gnc-register-simple-boolean-option options
pagename-columns
optname-show-lot-guid-column
"a"
@@ -242,19 +257,29 @@
(N_ "Show end date amount and value table columns")
#t)
- (gnc-register-simple-boolean-option options
+ (gnc-register-complex-boolean-option options
pagename-columns
optname-show-realized-gain-columns
"f"
(N_ "Show realized gain table column(s) for sold shares")
- #t)
+ #t
+ (lambda (x)
+ (update-gain-percentage-columns-enabled
+ (or x (gnc-optiondb-lookup-value options
+ pagename-columns
+ optname-show-unrealized-gain-columns)))))
- (gnc-register-simple-boolean-option options
+ (gnc-register-complex-boolean-option options
pagename-columns
optname-show-unrealized-gain-columns
"g"
(N_ "Show unrealized gain table column(s) for unsold shares")
- #t)
+ #t
+ (lambda (x)
+ (update-gain-percentage-columns-enabled
+ (or x (gnc-optiondb-lookup-value options
+ pagename-columns
+ optname-show-realized-gain-columns)))))
(gnc-register-multichoice-callback-option options
pagename-columns
@@ -285,6 +310,20 @@
10E9 ;; upper-bound
1) ;; step-size
+ (gnc-register-simple-boolean-option options
+ pagename-columns
+ optname-show-roi-columns
+ "j"
+ (N_ "Show Return On Investment (ROI) columns")
+ #f)
+
+ (gnc-register-simple-boolean-option options
+ pagename-columns
+ optname-show-cagr-columns
+ "k"
+ (N_ "Show Compound Annual Growth Rate (CAGR) columns")
+ #t)
+
;; Display tab
(gnc-register-simple-boolean-option options
gnc:pagename-display
@@ -325,6 +364,14 @@
(gnc:options-add-date-interval!
options gnc:pagename-general optname-from-date optname-to-date "a")
+ ;; Use a default report date of 'today'. Otherwise,
+ ;; 'end of accounting period' would be the default. A future date
+ ;; makes a poor default because they may result in misleading/non-sensical
+ ;; unrealized CAGR percentages.
+ (GncOption-set-default-value
+ (gnc-lookup-option options gnc:pagename-general optname-to-date)
+ 'today)
+
(gnc:options-add-currency!
options
gnc:pagename-general
@@ -392,6 +439,165 @@
options))
+;; Calculate Compound Annual Growth Rate (CAGR)
+;; basis: starting value (numeric or false)
+;; end-value: ending value (numeric)
+;; years: holding period in years (numeric or false)
+;; Returns: decimal (0.0 = 0% gain, 1.0 = 100% gain), or #f if undefined
+(define (calculate-cagr basis end-value years)
+ "Calculate compound annual growth rate.
+Returns #f if calculation is not possible, otherwise returns decimal CAGR."
+ (if (or (eq? basis #f)
+ (gnc-numeric-zero-p basis)
+ (gnc-numeric-negative-p basis)
+ (gnc-numeric-negative-p end-value)
+ (eq? years #f)
+ (zero? years))
+ #f
+ (- (expt (/ (gnc-numeric-to-double end-value)
+ (gnc-numeric-to-double basis))
+ (/ 1 years))
+ 1)))
+
+;; Calculate simple gain or loss
+;; basis: cost basis (numeric)
+;; end-value: current or sale value (numeric)
+;; Returns: gain/loss as gnc-numeric
+(define (calculate-gain basis end-value)
+ "Calculate gain or loss.
+Returns gnc-numeric value of gain or loss."
+ (gnc-numeric-sub-fixed end-value basis))
+
+;; Calculate return on investment (ROI) percentage
+;; basis: cost basis (numeric)
+;; gain: realized or unrealized gain (numeric)
+;; Returns: decimal (0.0 = 0%, 1.0 = 100%), or #f if undefined
+(define (calculate-roi basis gain)
+ "Calculate return on investment percentage.
+Returns decimal percentage (0.0 = 0%, 1.0 = 100%), or #f if zero basis."
+ (if (gnc-numeric-zero-p basis)
+ #f
+ (/ (gnc-numeric-to-double gain)
+ (gnc-numeric-to-double basis))))
+
+;; Determine if a holding qualifies as long-term based on holding period
+;; buy-date: acquisition date (time64)
+;; sell-date: sale or valuation date (time64)
+;; long-term-years: threshold in years
+;; Returns: #t if held >= long-term-years, #f otherwise
+(define (is-long-term? buy-date sell-date long-term-years)
+ "Determine if holding period qualifies as long-term.
+Returns #t if held >= long-term-years, #f otherwise."
+ (let* ((seconds-per-day 86400)
+ (days-per-year 365.25)
+ (days-held (/ (- sell-date buy-date) seconds-per-day))
+ (years-held (/ days-held days-per-year)))
+ (>= years-held long-term-years)))
+
+;; Compute aggregate gain metrics used by renderer formatting.
+;; Returns list: (total-gain roi-percent cagr-percent)
+(define (compute-gain-metrics basis short-gain long-gain cagr-value)
+ (let* ((total-gain (gnc-numeric-add-fixed short-gain long-gain))
+ (roi-decimal
+ (cond
+ ((or (not basis)
+ (not total-gain))
+ #f)
+ ((gnc-numeric-zero-p basis)
+ 0)
+ (else (calculate-roi basis total-gain))))
+ (roi-percent (and roi-decimal (* 100 roi-decimal)))
+ (cagr-percent (and cagr-value (* 100 cagr-value))))
+ (list total-gain roi-percent cagr-percent)))
+
+;; True when split metadata indicates a stock split event.
+(define (stock-split-event? split-type split-action)
+ (or (and split-type (string=? split-type "stock-split"))
+ (and split-action (string-ci=? split-action "split"))))
+
+;; True for reverse stock split style share reductions: negative amount,
+;; zero value, and stock split metadata. These are non-realizing events and
+;; should not affect realized gains or basis.
+(define (non-realizing-share-reduction?
+ split-type split-action amount value)
+ (and (stock-split-event? split-type split-action)
+ (gnc-numeric-negative-p amount)
+ (gnc-numeric-zero-p value)))
+
+;; Classify a split event for lot processing.
+(define (classify-lot-split split-type split-action amount value)
+ (cond
+ ((non-realizing-share-reduction?
+ split-type split-action amount value)
+ 'non-realizing-share-reduction)
+ ((gnc-numeric-positive-p amount) 'purchase)
+ ((gnc-numeric-negative-p amount) 'sale)
+ ((gnc-numeric-zero-p amount) 'realized-gain-split)
+ (else 'other)))
+
+;; Returns a pair-like list: (lots unassigned-splits)
+;; where lots are unique lots referenced by splits.
+(define (collect-lots-and-unassigned-splits splits)
+ (define lots-seen (make-hash-table))
+ (let loop ((remaining splits)
+ (lots '())
+ (unassigned-splits '()))
+ (match remaining
+ (()
+ (list (reverse lots) unassigned-splits))
+ ((split . rest)
+ (let ((lot (xaccSplitGetLot split)))
+ (loop rest
+ (cond
+ ((or (null? lot)
+ (hash-ref lots-seen lot))
+ lots)
+ (else
+ (hash-set! lots-seen lot #t)
+ (cons lot lots)))
+ (cond
+ ((null? lot)
+ (cons split unassigned-splits))
+ (else unassigned-splits))))))))
+
+;; Returns #t when s1 should sort before s2 for lot processing:
+;; first by transaction date, then purchases before sales on same date.
+(define (split-sort<=? s1 s2)
+ (let* ((t1 (xaccSplitGetParent s1))
+ (t2 (xaccSplitGetParent s2))
+ (date1 (xaccTransGetDate t1))
+ (date2 (xaccTransGetDate t2))
+ (date-order (cond
+ ((< date1 date2) -1)
+ ((> date1 date2) 1)
+ (else 0))))
+ (if (= date-order 0)
+ (let ((is-purchase-s1
+ (gnc-numeric-positive-p (xaccSplitGetAmount s1)))
+ (is-purchase-s2
+ (gnc-numeric-positive-p (xaccSplitGetAmount s2))))
+ (cond
+ ((and is-purchase-s1 is-purchase-s2)
+ ;; Same-day purchases: defer to transaction ordering as tiebreak.
+ (<= (xaccTransOrder t1 t2) 0))
+ (else is-purchase-s1)))
+ (<= date-order 0))))
+
+;; Returns lot splits up to and including to-date, sorted consistently.
+(define (lot-splits-up-to-date lot to-date)
+ (sort-list!
+ (let loop ((remaining (gnc-lot-get-split-list lot))
+ (result '()))
+ (match remaining
+ (() result)
+ ((split . rest)
+ (let ((split-date (xaccTransGetDate (xaccSplitGetParent split))))
+ (loop rest
+ (if (<= split-date to-date)
+ (cons split result)
+ result))))))
+ split-sort<=?))
+
;; This is the rendering function. It accepts a database of options
;; and generates an object of type <html-document>. See the file
;; report-html.txt for documentation; the file report-html.scm
@@ -402,8 +608,7 @@
;; This is a helper function for looking up option values.
(define (get-option section name)
- (gnc:option-value
- (gnc:lookup-option (gnc:report-options report-obj) section name)))
+ (gnc-optiondb-lookup-value (gnc:report-options report-obj) section name))
;; Given a price list and a currency find the price for that currency on the
;; list. If there is none for the requested currency, return the first one.
@@ -434,7 +639,7 @@
;; Chart options
(show-chart (get-option pagename-chart optname-show-chart))
(chart-type (get-option pagename-chart optname-chart-type))
- (chart-location (get-option pagename-chart optname-chart-location))
+ (chart-location (get-option pagename-chart optname-chart-location))
(chart-height (get-option pagename-chart optname-plot-height))
(chart-width (get-option pagename-chart optname-plot-width))
@@ -458,6 +663,10 @@
(get-option pagename-columns optname-group-gains-by-age))
(long-term-years
(get-option pagename-columns optname-long-term-years))
+ (show-roi-columns
+ (get-option pagename-columns optname-show-roi-columns))
+ (show-cagr-columns
+ (get-option pagename-columns optname-show-cagr-columns))
;; Display options
(include-closed-lots
@@ -578,6 +787,11 @@
(> years-held long-term-years))
#f))
+ ;; Returns the Compound Annual Growth Rate (CAGR). Returns false if basis
+ ;; and/or years are zero or false.
+ (define (get-cagr basis end-value years)
+ (calculate-cagr basis end-value years))
+
;; Gets the account name.
(define (account->name account)
(if show-long-account-names
@@ -683,77 +897,18 @@
;; splits. The second item is the number of splits that are not assigned
;; to a lot.
(define (get-all-lots splits)
- (define lots-seen (make-hash-table))
- (let loop ((splits splits)
- (lots '())
- (unassigned-splits '()))
- (match splits
- (()
- (gnc:debug (format #f "Found ~a lots and ~a unassigned splits"
- (length lots)
- (length unassigned-splits)))
- (list (reverse lots) unassigned-splits))
- ((split . rest)
- (let ((lot (xaccSplitGetLot split)))
- (loop rest
- (cond
- ((or (null? lot)
- (hash-ref lots-seen lot))
- lots)
- (else
- (hash-set! lots-seen lot #t)
- (cons lot lots)))
- (cond
- ((null? lot)
- (cons split unassigned-splits))
- (else unassigned-splits))))))))
+ (let* ((result (collect-lots-and-unassigned-splits splits))
+ (lots (car result))
+ (unassigned-splits (cadr result)))
+ (gnc:debug (format #f "Found ~a lots and ~a unassigned splits"
+ (length lots)
+ (length unassigned-splits)))
+ result))
;; Returns the lot splits, ordered first by transaction date and then
;; ordering purchases before sales.
(define (lot->splits lot)
- (sort-list!
- ;; Prune out splits that are after to-date.
- (let loop ((splits (gnc-lot-get-split-list lot))
- (result '()))
- (match splits
- (() result)
- ((split . rest)
- (loop rest
- (if (<= (split->date split) to-date)
- (cons split result)
- result)))))
- (lambda (s1 s2)
- (let* ((t1 (xaccSplitGetParent s1))
- (t2 (xaccSplitGetParent s2))
- (date1 (xaccTransGetDate t1))
- (date2 (xaccTransGetDate t2))
- ;; Do not call xaccTransOrder to set t-order. It not only
- ;; sorts by date posted, but by other fields that we don't
- ;; care about here (i.e. num, date entered, description, and
- ;; guid). When two transactions have the same date, we want
- ;; t-order to be zero, regardless of those other fields, so
- ;; that the secondary sorting logic (purchase or sale) takes
- ;; effect.
- ;; (t-order (xaccTransOrder t1 t2))
- (t-order (cond
- ((< date1 date2) -1)
- ((> date1 date2) 1)
- (else 0))))
- (if (= t-order 0)
- ;; The two splits share the same transaction date. Order
- ;; purchases before sales.
- (let ((is-purchase-s1
- (gnc-numeric-positive-p (xaccSplitGetAmount s1)))
- (is-purchase-s2
- (gnc-numeric-positive-p (xaccSplitGetAmount s2))))
- (cond
- ((and is-purchase-s1 is-purchase-s2)
- ;; They are both purchases and on the same date. So go
- ;; ahead and let xaccTransOrder be the tiebreaker (not
- ;; that it matters much).
- (<= (xaccTransOrder t1 t2) 0))
- (else is-purchase-s1)))
- (<= t-order 0))))))
+ (lot-splits-up-to-date lot to-date))
;; Gets the price's time.
(define (price->time price)
@@ -830,6 +985,28 @@
;; accounts may have different commodities, so combining their amounts
;; would not make sense).
(define (get-column-header-list is-grand-total)
+
+ ;; Helper function for getting the list of gains-related column names.
+ (define (get-gains-column-header-list
+ colname-short-term-gain
+ colname-long-term-gain
+ colname-gain
+ colname-roi
+ colname-cagr)
+ (append
+ (if group-gains-by-age
+ (list
+ colname-short-term-gain
+ colname-long-term-gain)
+ (list
+ colname-gain))
+ (if show-roi-columns
+ (list colname-roi)
+ '())
+ (if show-cagr-columns
+ (list
+ (if is-grand-total #f colname-cagr))
+ '())))
(append
(list (if is-grand-total #f colname-lot-title))
(if show-lot-guid-column
@@ -871,24 +1048,20 @@
colname-end-value)
'())
(if show-realized-gain-columns
- (if group-gains-by-age
- (list
- colname-short-term-realized-gain
- colname-long-term-realized-gain
- colname-realized-roi)
- (list
- colname-realized-gain
- colname-realized-roi))
+ (get-gains-column-header-list
+ colname-short-term-realized-gain
+ colname-long-term-realized-gain
+ colname-realized-gain
+ colname-realized-roi
+ colname-realized-cagr)
'())
(if show-unrealized-gain-columns
- (if group-gains-by-age
- (list
- colname-short-term-unrealized-gain
- colname-long-term-unrealized-gain
- colname-unrealized-roi)
- (list
- colname-unrealized-gain
- colname-unrealized-roi))
+ (get-gains-column-header-list
+ colname-short-term-unrealized-gain
+ colname-long-term-unrealized-gain
+ colname-unrealized-gain
+ colname-unrealized-roi
+ colname-unrealized-cagr)
'())))
;; The number of table columns.
@@ -965,8 +1138,10 @@
end-value
short-term-realized-gain
long-term-realized-gain
+ realized-cagr
short-term-unrealized-gain
- long-term-unrealized-gain)
+ long-term-unrealized-gain
+ unrealized-cagr)
;; Helper function for converting a numeric value to an html table cell.
(define (to-cell val format-val-fn)
(if (or (not val)
@@ -1022,29 +1197,33 @@
(value->monetary value))))
;; Helper function for adding capital gains columns
- (define (get-gains-fn show-columns basis short-gain long-gain)
+ (define (get-gains-fn show-columns basis short-gain long-gain cagr-value)
(append
(if show-columns
- (let* ((total-gain (gnc-numeric-add-fixed
- short-gain
- long-gain))
- (roi (percentage->cell
- (cond
- ((or (not basis)
- (not total-gain))
- #f)
- ((gnc-numeric-zero-p basis)
- 0)
- (else
- (* 100 (/ total-gain basis)))))))
- (if group-gains-by-age
- (list
- (value->cell short-gain)
- (value->cell long-gain)
- roi)
- (list
- (value->cell total-gain)
- roi)))
+ (let* ((gain-metrics
+ (compute-gain-metrics
+ basis
+ short-gain
+ long-gain
+ cagr-value))
+ (total-gain (car gain-metrics))
+ (roi-percent (cadr gain-metrics))
+ (cagr-percent (caddr gain-metrics))
+ (roi (percentage->cell roi-percent))
+ (cagr (percentage->cell cagr-percent)))
+ (append
+ (if group-gains-by-age
+ (list
+ (value->cell short-gain)
+ (value->cell long-gain))
+ (list
+ (value->cell total-gain)))
+ (if show-roi-columns
+ (list roi)
+ '())
+ (if show-cagr-columns
+ (list cagr)
+ '())))
'())))
(if is-bold
@@ -1113,12 +1292,14 @@
show-realized-gain-columns
sold-basis
short-term-realized-gain
- long-term-realized-gain)
+ long-term-realized-gain
+ realized-cagr)
(get-gains-fn
show-unrealized-gain-columns
end-basis
short-term-unrealized-gain
- long-term-unrealized-gain))))
+ long-term-unrealized-gain
+ unrealized-cagr))))
(gnc:html-table-append-row/markup!
table
(if is-bold "grand-total" (get-row-style is-odd-row))
@@ -1163,12 +1344,14 @@
(long-term-sold-basis (get-report-value-zero))
(long-term-sold-value (get-report-value-zero))
(long-term-realized-gain (get-report-value-zero))
+ (weighted-realized-cagr 0.0)
(end-amount (get-amount-zero))
(end-basis (get-report-value-zero))
(end-value (get-report-value-zero))
(unrealized-gain (get-report-value-zero))
(short-term-unrealized-gain (get-report-value-zero))
(long-term-unrealized-gain (get-report-value-zero))
+ (weighted-unrealized-cagr 0.0)
(has-warnings #f)
(is-active-in-window #f)
(currency '())
@@ -1221,7 +1404,8 @@
sold-basis
sold-value
sold-gain
- is-long-term)
+ is-long-term
+ cagr)
(if show-split-rows
(let ((date-cell
(to-split-cell (qof-print-date trans-date) split))
@@ -1260,8 +1444,10 @@
(if (and is-long-term sold-gain) 0 sold-gain)
;; long-term-realized-gain
(if (and is-long-term sold-gain) sold-gain 0)
+ cagr ;; realized-cagr
#f ;; short-term-unrealized-gain
- #f)))) ;; long-term-unrealized-gain
+ #f ;; long-term-unrealized-gain
+ #f)))) ;; unrealized-cagr
;; Adds the stats to the given html table.
(define (add-to-table table is-odd-row)
@@ -1287,7 +1473,32 @@
(cond
(is-lot-row (lot->title lot))
(is-account-row label-account-total)
- (is-grand-total-row label-grand-total))))
+ (is-grand-total-row label-grand-total)))
+ (years-held
+ (cond
+ (is-lot-row
+ (gnc:date-year-delta
+ latest-bought-split-date
+ to-date))
+ (else #f)))
+ (unrealized-cagr
+ (cond
+ (is-lot-row (get-cagr end-basis end-value years-held))
+ (is-account-row
+ (if (gnc-numeric-zero-p end-amount)
+ 0
+ (/ weighted-unrealized-cagr end-amount)))
+ (is-grand-total-row #f)))
+ (sold-amount (gnc-numeric-add-fixed
+ long-term-sold-amount
+ short-term-sold-amount))
+ (realized-cagr
+ (cond
+ ((or is-lot-row is-account-row)
+ (if (gnc-numeric-zero-p sold-amount)
+ 0
+ (/ weighted-realized-cagr sold-amount)))
+ (is-grand-total-row #f))))
(add-data-row
table
(if (not is-grand-total-row) currency #f)
@@ -1311,11 +1522,18 @@
end-value
short-term-realized-gain
long-term-realized-gain
+ (if (not is-grand-total-row) realized-cagr #f)
short-term-unrealized-gain
- long-term-unrealized-gain)
+ long-term-unrealized-gain
+ (if (not is-grand-total-row) unrealized-cagr #f))
(if is-lot-row
- (copy-table-rows splits-table table (get-row-style is-odd-row)))
+ (begin
+ (if unrealized-cagr
+ (set! weighted-unrealized-cagr
+ (+ weighted-unrealized-cagr
+ (* unrealized-cagr end-amount))))
+ (copy-table-rows splits-table table (get-row-style is-odd-row))))
(add-warnings-to-table table)
@@ -1513,28 +1731,99 @@
(xaccSplitGetValue split)
trans-currency))
(amount (xaccSplitGetAmount split))
- (is-purchase (gnc-numeric-positive-p amount))
- (is-sale (gnc-numeric-negative-p amount))
- (is-realized-gain (gnc-numeric-zero-p amount)))
+ (split-type (xaccSplitGetType split))
+ (split-action (xaccSplitGetAction split))
+ (split-kind
+ (classify-lot-split split-type split-action amount value)))
- (cond
- (is-purchase
+ (case split-kind
+ ((purchase)
(merge-purchase-split split trans-date amount value))
- (is-sale
+ ;; Reverse stock splits (or equivalent metadata-tagged share
+ ;; reductions) are non-realizing events: shares decrease with no
+ ;; proceeds, so basis and realized gain must remain unchanged.
+ ((non-realizing-share-reduction)
+ (merge-non-realizing-share-reduction-split
+ split
+ trans-date
+ ;; Convert to positive share reduction.
+ (gnc-numeric-neg amount)))
+
+ ((sale)
(merge-sale-split
split
trans-date
- ;; Covert amount and value to positive numbers.
+ ;; Convert amount and value to positive numbers.
(gnc-numeric-neg amount)
(gnc-numeric-neg value)))
;; A "Realized Gain/Loss" split has zero amount. Sum its value
;; to validate against the report-computed gain value.
- ((and is-realized-gain
- (>= trans-date from-date))
+ ((realized-gain-split)
+ (if (>= trans-date from-date)
(set! splits-realized-gain
- (gnc-numeric-add-fixed splits-realized-gain value))))))
+ (gnc-numeric-add-fixed splits-realized-gain value))))
+
+ (else #f))))
+
+ ;; Merges in a non-realizing share reduction (e.g. reverse stock
+ ;; split). Shares are reduced, but basis and realized gains are not.
+ (define (merge-non-realizing-share-reduction-split
+ split trans-date amount)
+ (set! end-amount (gnc-numeric-sub-fixed end-amount amount))
+ (if (and (>= trans-date from-date)
+ (null? first-negative-split)
+ (gnc-numeric-negative-p end-amount))
+ (set! first-negative-split split)))
+
+ ;; Adds sale metrics into either the long-term or short-term
+ ;; accumulator buckets.
+ (define (accumulate-sale-term! is-long-term amount basis value gain)
+ (if is-long-term
+ (begin
+ (set! long-term-sold-amount
+ (gnc-numeric-add-fixed long-term-sold-amount amount))
+ (set! long-term-sold-basis
+ (gnc-numeric-add-fixed long-term-sold-basis basis))
+ (set! long-term-sold-value
+ (gnc-numeric-add-fixed long-term-sold-value value))
+ (set! long-term-realized-gain
+ (gnc-numeric-add-fixed long-term-realized-gain gain)))
+ (begin
+ (set! short-term-sold-amount
+ (gnc-numeric-add-fixed short-term-sold-amount amount))
+ (set! short-term-sold-basis
+ (gnc-numeric-add-fixed short-term-sold-basis basis))
+ (set! short-term-sold-value
+ (gnc-numeric-add-fixed short-term-sold-value value))
+ (set! short-term-realized-gain
+ (gnc-numeric-add-fixed short-term-realized-gain gain)))))
+
+ ;; Adds purchase metrics into current-window or pre-window buckets.
+ (define (accumulate-purchase! trans-date amount value)
+ (if (>= trans-date from-date)
+ (begin
+ (set! bought-amount
+ (gnc-numeric-add-fixed bought-amount amount))
+ (set! bought-value
+ (gnc-numeric-add-fixed bought-value value)))
+ (begin
+ (set! old-bought-value
+ (gnc-numeric-add-fixed old-bought-value value))
+ (set! old-bought-amount
+ (gnc-numeric-add-fixed old-bought-amount amount)))))
+
+ ;; Generic field merge helpers used by merge-stats.
+ (define (merge-fixed-field! stats get-current set-current stats-key)
+ (set-current
+ (gnc-numeric-add-fixed (get-current) (stats stats-key))))
+
+ (define (merge-double-field! stats get-current set-current stats-key)
+ (set-current (+ (get-current) (stats stats-key))))
+
+ (define (merge-boolean-or-field! stats get-current set-current stats-key)
+ (set-current (or (get-current) (stats stats-key))))
;; Merges in the sale split info.
(define (merge-sale-split split trans-date amount value)
@@ -1572,45 +1861,40 @@
(cond
((>= trans-date from-date)
- ;; Remember if a sale within the report window causes the
- ;; lot's balance to go negative.
- (if (and (null? first-negative-split)
- (gnc-numeric-negative-p end-amount))
- (set! first-negative-split split))
-
- (cond
- (is-long-term
- (set! long-term-sold-amount
- (gnc-numeric-add-fixed long-term-sold-amount amount))
- (set! long-term-sold-basis
- (gnc-numeric-add-fixed long-term-sold-basis basis))
- (set! long-term-sold-value
- (gnc-numeric-add-fixed long-term-sold-value value))
- (set! long-term-realized-gain
- (gnc-numeric-add-fixed long-term-realized-gain gain)))
- (else
- (set! short-term-sold-amount
- (gnc-numeric-add-fixed short-term-sold-amount amount))
- (set! short-term-sold-basis
- (gnc-numeric-add-fixed short-term-sold-basis basis))
- (set! short-term-sold-value
- (gnc-numeric-add-fixed short-term-sold-value value))
- (set! short-term-realized-gain
- (gnc-numeric-add-fixed short-term-realized-gain gain))))
-
- (add-split-row
- split
- trans-date
- #f ;; bought-amount
- #f ;; bought-value
- amount ;; sold-amount
- basis ;; sold-basis
- value ;; sold-value
- gain ;; sold-gain
- is-long-term)
-
- (set! sold-split-count (+ sold-split-count 1))
- (set! last-sold-split split)))))
+ (let* ((years-held
+ (gnc:date-year-delta
+ latest-bought-split-date
+ trans-date))
+ (cagr (get-cagr basis value years-held)))
+ ;; Remember if a sale within the report window causes the
+ ;; lot's balance to go negative.
+ (if (and (null? first-negative-split)
+ (gnc-numeric-negative-p end-amount))
+ (set! first-negative-split split))
+
+ ;; if CAGR could not be computed because the basis and/or the
+ ;; years held are zero (like if a security was bought and sold
+ ;; on the same day), then do not include it in the total.
+ (if cagr
+ (set! weighted-realized-cagr
+ (+ weighted-realized-cagr (* cagr amount))))
+
+ (accumulate-sale-term! is-long-term amount basis value gain)
+
+ (add-split-row
+ split
+ trans-date
+ #f ;; bought-amount
+ #f ;; bought-value
+ amount ;; sold-amount
+ basis ;; sold-basis
+ value ;; sold-value
+ gain ;; sold-gain
+ is-long-term
+ cagr)
+
+ (set! sold-split-count (+ sold-split-count 1))
+ (set! last-sold-split split))))))
;; Merges in the purchase split info.
(define (merge-purchase-split split trans-date amount value)
@@ -1631,12 +1915,13 @@
(set! latest-bought-split-date trans-date))
(set! end-basis (gnc-numeric-add-fixed end-basis value))
(set! end-amount (gnc-numeric-add-fixed end-amount amount))
+
+ ;; Always accumulate purchase totals; row rendering still depends on
+ ;; whether the purchase occurred inside the report window.
+ (accumulate-purchase! trans-date amount value)
+
(cond
((>= trans-date from-date)
- (set! bought-amount
- (gnc-numeric-add-fixed bought-amount amount))
- (set! bought-value
- (gnc-numeric-add-fixed bought-value value))
(add-split-row
split
trans-date
@@ -1646,16 +1931,13 @@
#f ;; sold-basis
#f ;; sold-value
#f ;; sold-gain
- #f)) ;; is-long-term
+ #f ;; is-long-term
+ #f)) ;; cagr
;; The split is from before the report's start date.
;; So we won't include it in the report table, but
;; we still need to count it for basis calculations.
- (else
- (set! old-bought-value
- (gnc-numeric-add-fixed old-bought-value value))
- (set! old-bought-amount
- (gnc-numeric-add-fixed old-bought-amount amount))))
+ (else #f))
;; Note that this also counts purchases before the report
;; start date.
@@ -1675,83 +1957,101 @@
(else #f))))
(set! bought-split-count
(+ bought-split-count (stats 'get-bought-split-count)))
- (set! old-bought-value
- (gnc-numeric-add-fixed
- old-bought-value
- (stats 'get-old-bought-value)))
- (set! bought-value
- (gnc-numeric-add-fixed bought-value (stats 'get-bought-value)))
- (set! sold-split-count
- (gnc-numeric-add-fixed
- sold-split-count
- (stats 'get-sold-split-count)))
- (set! short-term-sold-basis
- (gnc-numeric-add-fixed
- short-term-sold-basis
- (stats 'get-short-term-sold-basis)))
- (set! short-term-sold-value
- (gnc-numeric-add-fixed
- short-term-sold-value
- (stats 'get-short-term-sold-value)))
- (set! short-term-realized-gain
- (gnc-numeric-add-fixed
- short-term-realized-gain
- (stats 'get-short-term-realized-gain)))
- (set! long-term-sold-basis
- (gnc-numeric-add-fixed
- long-term-sold-basis
- (stats 'get-long-term-sold-basis)))
- (set! long-term-sold-value
- (gnc-numeric-add-fixed
- long-term-sold-value
- (stats 'get-long-term-sold-value)))
- (set! long-term-realized-gain
- (gnc-numeric-add-fixed
- long-term-realized-gain
- (stats 'get-long-term-realized-gain)))
- (set! end-basis
- (gnc-numeric-add-fixed end-basis (stats 'get-end-basis)))
- (set! end-value
- (gnc-numeric-add-fixed end-value (stats 'get-end-value)))
- (set! unrealized-gain
- (gnc-numeric-add-fixed
- unrealized-gain
- (stats 'get-unrealized-gain)))
- (set! short-term-unrealized-gain
- (gnc-numeric-add-fixed
- short-term-unrealized-gain
- (stats 'get-short-term-unrealized-gain)))
- (set! long-term-unrealized-gain
- (gnc-numeric-add-fixed
- long-term-unrealized-gain
- (stats 'get-long-term-unrealized-gain)))
- (set! has-warnings
- (or has-warnings
- (stats 'get-has-warnings)))
- (set! is-active-in-window
- (or is-active-in-window
- (stats 'get-is-active-in-window)))
+ (merge-fixed-field! stats
+ (lambda () old-bought-value)
+ (lambda (v) (set! old-bought-value v))
+ 'get-old-bought-value)
+ (merge-fixed-field! stats
+ (lambda () bought-value)
+ (lambda (v) (set! bought-value v))
+ 'get-bought-value)
+ (merge-fixed-field! stats
+ (lambda () sold-split-count)
+ (lambda (v) (set! sold-split-count v))
+ 'get-sold-split-count)
+ (merge-fixed-field! stats
+ (lambda () short-term-sold-basis)
+ (lambda (v) (set! short-term-sold-basis v))
+ 'get-short-term-sold-basis)
+ (merge-fixed-field! stats
+ (lambda () short-term-sold-value)
+ (lambda (v) (set! short-term-sold-value v))
+ 'get-short-term-sold-value)
+ (merge-fixed-field! stats
+ (lambda () short-term-realized-gain)
+ (lambda (v) (set! short-term-realized-gain v))
+ 'get-short-term-realized-gain)
+ (merge-fixed-field! stats
+ (lambda () long-term-sold-basis)
+ (lambda (v) (set! long-term-sold-basis v))
+ 'get-long-term-sold-basis)
+ (merge-fixed-field! stats
+ (lambda () long-term-sold-value)
+ (lambda (v) (set! long-term-sold-value v))
+ 'get-long-term-sold-value)
+ (merge-fixed-field! stats
+ (lambda () long-term-realized-gain)
+ (lambda (v) (set! long-term-realized-gain v))
+ 'get-long-term-realized-gain)
+ (merge-double-field! stats
+ (lambda () weighted-realized-cagr)
+ (lambda (v) (set! weighted-realized-cagr v))
+ 'get-weighted-realized-cagr)
+ (merge-fixed-field! stats
+ (lambda () end-basis)
+ (lambda (v) (set! end-basis v))
+ 'get-end-basis)
+ (merge-fixed-field! stats
+ (lambda () end-value)
+ (lambda (v) (set! end-value v))
+ 'get-end-value)
+ (merge-fixed-field! stats
+ (lambda () unrealized-gain)
+ (lambda (v) (set! unrealized-gain v))
+ 'get-unrealized-gain)
+ (merge-fixed-field! stats
+ (lambda () short-term-unrealized-gain)
+ (lambda (v) (set! short-term-unrealized-gain v))
+ 'get-short-term-unrealized-gain)
+ (merge-fixed-field! stats
+ (lambda () long-term-unrealized-gain)
+ (lambda (v) (set! long-term-unrealized-gain v))
+ 'get-long-term-unrealized-gain)
+ (merge-double-field! stats
+ (lambda () weighted-unrealized-cagr)
+ (lambda (v) (set! weighted-unrealized-cagr v))
+ 'get-weighted-unrealized-cagr)
+ (merge-boolean-or-field! stats
+ (lambda () has-warnings)
+ (lambda (v) (set! has-warnings v))
+ 'get-has-warnings)
+ (merge-boolean-or-field! stats
+ (lambda () is-active-in-window)
+ (lambda (v) (set! is-active-in-window v))
+ 'get-is-active-in-window)
(if include-amounts
(begin
- (set! old-bought-amount
- (gnc-numeric-add-fixed
- old-bought-amount
- (stats 'get-old-bought-amount)))
- (set! bought-amount
- (gnc-numeric-add-fixed
- bought-amount
- (stats 'get-bought-amount)))
- (set! short-term-sold-amount
- (gnc-numeric-add-fixed
- short-term-sold-amount
- (stats 'get-short-term-sold-amount)))
- (set! long-term-sold-amount
- (gnc-numeric-add-fixed
- long-term-sold-amount
- (stats 'get-long-term-sold-amount)))
- (set! end-amount
- (gnc-numeric-add-fixed end-amount (stats 'get-end-amount)))
+ (merge-fixed-field! stats
+ (lambda () old-bought-amount)
+ (lambda (v) (set! old-bought-amount v))
+ 'get-old-bought-amount)
+ (merge-fixed-field! stats
+ (lambda () bought-amount)
+ (lambda (v) (set! bought-amount v))
+ 'get-bought-amount)
+ (merge-fixed-field! stats
+ (lambda () short-term-sold-amount)
+ (lambda (v) (set! short-term-sold-amount v))
+ 'get-short-term-sold-amount)
+ (merge-fixed-field! stats
+ (lambda () long-term-sold-amount)
+ (lambda (v) (set! long-term-sold-amount v))
+ 'get-long-term-sold-amount)
+ (merge-fixed-field! stats
+ (lambda () end-amount)
+ (lambda (v) (set! end-amount v))
+ 'get-end-amount)
;; The amounts are being combined, so they must all pertain to
;; the same currency. Copy it, if not already set.
(if (null? currency)
@@ -1781,6 +2081,7 @@
((get-short-term-realized-gain)
(lambda () short-term-realized-gain))
((get-long-term-realized-gain) (lambda () long-term-realized-gain))
+ ((get-weighted-realized-cagr) (lambda () weighted-realized-cagr))
((get-end-amount) (lambda () end-amount))
((get-end-basis) (lambda () end-basis))
((get-end-value) (lambda () end-value))
@@ -1789,6 +2090,7 @@
(lambda () short-term-unrealized-gain))
((get-long-term-unrealized-gain)
(lambda () long-term-unrealized-gain))
+ ((get-weighted-unrealized-cagr) (lambda () weighted-unrealized-cagr))
((get-has-warnings) (lambda () has-warnings))
((get-is-active-in-window) (lambda () is-active-in-window))
((get-currency) (lambda () currency))
diff --git a/gnucash/report/reports/standard/test/CMakeLists.txt b/gnucash/report/reports/standard/test/CMakeLists.txt
index 49a850667e..773c0dd1d3 100644
--- a/gnucash/report/reports/standard/test/CMakeLists.txt
+++ b/gnucash/report/reports/standard/test/CMakeLists.txt
@@ -18,6 +18,7 @@ set(scm_test_with_srfi64_SOURCES
test-equity-statement.scm
test-average-balance.scm
test-ifrs-cost-basis.scm
+ test-investment-lots.scm
test-invoice.scm
test-new-owner-report.scm
test-owner-report.scm
diff --git a/gnucash/report/reports/standard/test/test-investment-lots.scm b/gnucash/report/reports/standard/test/test-investment-lots.scm
new file mode 100644
index 0000000000..e87371e755
--- /dev/null
+++ b/gnucash/report/reports/standard/test/test-investment-lots.scm
@@ -0,0 +1,836 @@
+(use-modules (tests test-engine-extras))
+(use-modules (gnucash reports standard investment-lots))
+(use-modules (gnucash report))
+(use-modules (tests test-report-extras))
+(use-modules (gnucash report stylesheets plain))
+(use-modules (tests srfi64-extras))
+(use-modules (srfi srfi-64))
+(use-modules (gnucash engine))
+(use-modules (gnucash app-utils))
+
+;; UUID for the investment-lots report
+(define uuid "ab2acc24afd14630a551f98f1a35fa81")
+
+;; Explicitly set locale to make the report output predictable
+(setlocale LC_ALL "C")
+
+;; Exercise production helper implementations directly.
+(define calculate-cagr
+ (@@ (gnucash reports standard investment-lots) calculate-cagr))
+(define calculate-gain
+ (@@ (gnucash reports standard investment-lots) calculate-gain))
+(define calculate-roi
+ (@@ (gnucash reports standard investment-lots) calculate-roi))
+(define is-long-term?
+ (@@ (gnucash reports standard investment-lots) is-long-term?))
+
+(define (run-test)
+ (test-runner-factory gnc:test-runner)
+ (test-begin "test-investment-lots")
+ (test-investment-lots)
+ (test-end "test-investment-lots"))
+
+(define (set-option! options page tag value)
+ (if (gnc-lookup-option (gnc:optiondb options) page tag)
+ (gnc-set-option (gnc:optiondb options) page tag value)
+ (begin
+ (test-assert (format #f "wrong-option ~a ~a" page tag) #f)
+ #f)))
+
+(define (options->sxml options test-title)
+ (gnc:options->sxml uuid options "test-investment-lots" test-title
+ #:strip-tag "script"))
+
+(define (render-report options test-title)
+ "Render report and return #t if successful, #f if error."
+ (catch #t
+ (lambda ()
+ (let ((result (options->sxml options test-title)))
+ (if result #t #f)))
+ (lambda (key . args)
+ #f)))
+
+(define (mnemonic->commodity sym)
+ (gnc-commodity-table-lookup
+ (gnc-commodity-table-get-table (gnc-get-current-book))
+ (gnc-commodity-get-namespace (gnc-default-report-currency))
+ sym))
+
+(define (create-investment-lots-test-data)
+ (let* ((book (gnc-get-current-book))
+ (env (create-test-env))
+ (USD (mnemonic->commodity "USD"))
+ (comm-table (gnc-commodity-table-get-table book))
+ ;; Create stock commodities
+ (AAPL (gnc-commodity-new book "Apple" "NASDAQ" "AAPL" "" 1))
+ (SPY (gnc-commodity-new book "SPY" "NYSE" "SPY" "" 1))
+ (structure
+ (list "Root" (list (cons 'type ACCT-TYPE-ASSET)
+ (cons 'commodity USD))
+ (list "Assets"
+ (list "Broker" (list (cons 'commodity USD))
+ (list "Cash" (list (cons 'commodity USD)))
+ (list "AAPL" (list (cons 'commodity AAPL)
+ (cons 'type ACCT-TYPE-STOCK)))
+ (list "SPY" (list (cons 'commodity SPY)
+ (cons 'type ACCT-TYPE-STOCK)))))
+ (list "Income" (list (cons 'type ACCT-TYPE-INCOME))))))
+
+ ;; Register commodities in the table
+ (gnc-commodity-table-insert comm-table AAPL)
+ (gnc-commodity-table-insert comm-table SPY)
+
+ (let* ((account-alist (env-create-account-structure-alist env structure))
+ (cash (cdr (assoc "Cash" account-alist)))
+ (aapl-acct (cdr (assoc "AAPL" account-alist)))
+ (spy-acct (cdr (assoc "SPY" account-alist)))
+ (income (cdr (assoc "Income" account-alist))))
+
+ ;; Create transactions forming two lots of AAPL
+ ;; Lot 1: Buy 100 shares at $50
+ (env-create-multisplit-transaction
+ env 01 01 2020
+ (list (vector cash -5000 -5000 "")
+ (vector aapl-acct 100 100 "Buy 100 shares"))
+ #:description "Buy AAPL lot 1"
+ #:currency USD)
+
+ ;; Lot 2: Buy 50 shares at $60
+ (env-create-multisplit-transaction
+ env 01 02 2020
+ (list (vector cash -3000 -3000 "")
+ (vector aapl-acct 50 50 "Buy 50 shares"))
+ #:description "Buy AAPL lot 2"
+ #:currency USD)
+
+ ;; Partial sale: 60 shares at $80
+ (env-create-multisplit-transaction
+ env 01 06 2020
+ (list (vector cash 4800 4800 "")
+ (vector aapl-acct -60 -60 "Sell 60 shares"))
+ #:description "Sell AAPL"
+ #:currency USD)
+
+ ;; Buy SPY for variety
+ (env-create-multisplit-transaction
+ env 01 01 2020
+ (list (vector cash -40000 -40000 "")
+ (vector spy-acct 200 200 "Buy 200 shares"))
+ #:description "Buy SPY"
+ #:currency USD)
+
+ account-alist)))
+
+(define (create-reverse-split-lot-test-data)
+ (let* ((book (gnc-get-current-book))
+ (env (create-test-env))
+ (USD (mnemonic->commodity "USD"))
+ (comm-table (gnc-commodity-table-get-table book))
+ (AAPL (gnc-commodity-new book "Apple" "NASDAQ" "AAPL" "" 1))
+ (structure
+ (list "Root" (list (cons 'type ACCT-TYPE-ASSET)
+ (cons 'commodity USD))
+ (list "Assets"
+ (list "Broker" (list (cons 'commodity USD))
+ (list "Cash" (list (cons 'commodity USD)))
+ (list "AAPL" (list (cons 'commodity AAPL)
+ (cons 'type ACCT-TYPE-STOCK))))))))
+
+ (gnc-commodity-table-insert comm-table AAPL)
+
+ (let* ((account-alist (env-create-account-structure-alist env structure))
+ (cash (cdr (assoc "Cash" account-alist)))
+ (aapl-acct (cdr (assoc "AAPL" account-alist)))
+ (lot (gnc-lot-new book))
+ (buy-txn (xaccMallocTransaction book))
+ (buy-stock (xaccMallocSplit book))
+ (buy-cash (xaccMallocSplit book))
+ (split-txn (xaccMallocTransaction book))
+ (split-split (xaccMallocSplit book)))
+
+ ;; Buy 100 shares into one explicit lot.
+ (xaccTransBeginEdit buy-txn)
+ (xaccTransSetCurrency buy-txn USD)
+ (xaccTransSetDate buy-txn 1 1 2020)
+ (xaccTransSetDescription buy-txn "Buy AAPL lot")
+
+ (xaccSplitSetAccount buy-stock aapl-acct)
+ (xaccSplitSetAmount buy-stock (gnc-numeric-create 100 1))
+ (xaccSplitSetValue buy-stock (gnc-numeric-create 5000 1))
+ (xaccSplitSetParent buy-stock buy-txn)
+ (gnc-lot-add-split lot buy-stock)
+
+ (xaccSplitSetAccount buy-cash cash)
+ (xaccSplitSetAmount buy-cash (gnc-numeric-create -5000 1))
+ (xaccSplitSetValue buy-cash (gnc-numeric-create -5000 1))
+ (xaccSplitSetParent buy-cash buy-txn)
+ (xaccTransCommitEdit buy-txn)
+
+ ;; Reverse split: remove 50 shares with zero proceeds.
+ (xaccTransBeginEdit split-txn)
+ (xaccTransSetCurrency split-txn USD)
+ (xaccTransSetDate split-txn 1 2 2020)
+ (xaccTransSetDescription split-txn "Reverse Split")
+
+ (xaccSplitSetAccount split-split aapl-acct)
+ (xaccSplitSetAmount split-split (gnc-numeric-create -50 1))
+ (xaccSplitSetValue split-split (gnc-numeric-create 0 1))
+ (xaccSplitMakeStockSplit split-split)
+ (xaccSplitSetAction split-split "Split")
+ (xaccSplitSetParent split-split split-txn)
+ (gnc-lot-add-split lot split-split)
+ (xaccTransCommitEdit split-txn)
+
+ account-alist)))
+
+(define (create-baseline-lot-test-data)
+ (let* ((book (gnc-get-current-book))
+ (env (create-test-env))
+ (USD (mnemonic->commodity "USD"))
+ (comm-table (gnc-commodity-table-get-table book))
+ (AAPL (gnc-commodity-new book "Apple" "NASDAQ" "AAPL" "" 1))
+ (structure
+ (list "Root" (list (cons 'type ACCT-TYPE-ASSET)
+ (cons 'commodity USD))
+ (list "Assets"
+ (list "Broker" (list (cons 'commodity USD))
+ (list "Cash" (list (cons 'commodity USD)))
+ (list "AAPL" (list (cons 'commodity AAPL)
+ (cons 'type ACCT-TYPE-STOCK))))))))
+
+ (gnc-commodity-table-insert comm-table AAPL)
+
+ (let* ((account-alist (env-create-account-structure-alist env structure))
+ (cash (cdr (assoc "Cash" account-alist)))
+ (aapl-acct (cdr (assoc "AAPL" account-alist)))
+ (lot (gnc-lot-new book))
+ (buy-txn (xaccMallocTransaction book))
+ (buy-stock (xaccMallocSplit book))
+ (buy-cash (xaccMallocSplit book)))
+
+ ;; Buy 100 shares into one explicit lot, with no split events.
+ (xaccTransBeginEdit buy-txn)
+ (xaccTransSetCurrency buy-txn USD)
+ (xaccTransSetDate buy-txn 1 1 2020)
+ (xaccTransSetDescription buy-txn "Buy AAPL lot baseline")
+
+ (xaccSplitSetAccount buy-stock aapl-acct)
+ (xaccSplitSetAmount buy-stock (gnc-numeric-create 100 1))
+ (xaccSplitSetValue buy-stock (gnc-numeric-create 5000 1))
+ (xaccSplitSetParent buy-stock buy-txn)
+ (gnc-lot-add-split lot buy-stock)
+
+ (xaccSplitSetAccount buy-cash cash)
+ (xaccSplitSetAmount buy-cash (gnc-numeric-create -5000 1))
+ (xaccSplitSetValue buy-cash (gnc-numeric-create -5000 1))
+ (xaccSplitSetParent buy-cash buy-txn)
+ (xaccTransCommitEdit buy-txn)
+
+ account-alist)))
+
+(define (create-reverse-split-with-sale-test-data)
+ (let* ((book (gnc-get-current-book))
+ (env (create-test-env))
+ (USD (mnemonic->commodity "USD"))
+ (comm-table (gnc-commodity-table-get-table book))
+ (AAPL (gnc-commodity-new book "Apple" "NASDAQ" "AAPL" "" 1))
+ (structure
+ (list "Root" (list (cons 'type ACCT-TYPE-ASSET)
+ (cons 'commodity USD))
+ (list "Assets"
+ (list "Broker" (list (cons 'commodity USD))
+ (list "Cash" (list (cons 'commodity USD)))
+ (list "AAPL" (list (cons 'commodity AAPL)
+ (cons 'type ACCT-TYPE-STOCK))))))))
+
+ (gnc-commodity-table-insert comm-table AAPL)
+
+ (let* ((account-alist (env-create-account-structure-alist env structure))
+ (cash (cdr (assoc "Cash" account-alist)))
+ (aapl-acct (cdr (assoc "AAPL" account-alist)))
+ (lot (gnc-lot-new book))
+ (buy-txn (xaccMallocTransaction book))
+ (buy-stock (xaccMallocSplit book))
+ (buy-cash (xaccMallocSplit book))
+ (split-txn (xaccMallocTransaction book))
+ (split-split (xaccMallocSplit book))
+ (sale-txn (xaccMallocTransaction book))
+ (sale-stock (xaccMallocSplit book))
+ (sale-cash (xaccMallocSplit book)))
+
+ ;; Buy 100 shares, $5,000 basis.
+ (xaccTransBeginEdit buy-txn)
+ (xaccTransSetCurrency buy-txn USD)
+ (xaccTransSetDate buy-txn 1 1 2020)
+ (xaccTransSetDescription buy-txn "Buy AAPL lot")
+ (xaccSplitSetAccount buy-stock aapl-acct)
+ (xaccSplitSetAmount buy-stock (gnc-numeric-create 100 1))
+ (xaccSplitSetValue buy-stock (gnc-numeric-create 5000 1))
+ (xaccSplitSetParent buy-stock buy-txn)
+ (gnc-lot-add-split lot buy-stock)
+ (xaccSplitSetAccount buy-cash cash)
+ (xaccSplitSetAmount buy-cash (gnc-numeric-create -5000 1))
+ (xaccSplitSetValue buy-cash (gnc-numeric-create -5000 1))
+ (xaccSplitSetParent buy-cash buy-txn)
+ (xaccTransCommitEdit buy-txn)
+
+ ;; Reverse split removes 50 shares, zero proceeds.
+ (xaccTransBeginEdit split-txn)
+ (xaccTransSetCurrency split-txn USD)
+ (xaccTransSetDate split-txn 1 2 2020)
+ (xaccTransSetDescription split-txn "Reverse Split")
+ (xaccSplitSetAccount split-split aapl-acct)
+ (xaccSplitSetAmount split-split (gnc-numeric-create -50 1))
+ (xaccSplitSetValue split-split (gnc-numeric-create 0 1))
+ (xaccSplitMakeStockSplit split-split)
+ (xaccSplitSetAction split-split "Split")
+ (xaccSplitSetParent split-split split-txn)
+ (gnc-lot-add-split lot split-split)
+ (xaccTransCommitEdit split-txn)
+
+ ;; Sell 25 shares for $3,000.
+ (xaccTransBeginEdit sale-txn)
+ (xaccTransSetCurrency sale-txn USD)
+ (xaccTransSetDate sale-txn 1 3 2020)
+ (xaccTransSetDescription sale-txn "Sell AAPL")
+ (xaccSplitSetAccount sale-stock aapl-acct)
+ (xaccSplitSetAmount sale-stock (gnc-numeric-create -25 1))
+ (xaccSplitSetValue sale-stock (gnc-numeric-create -3000 1))
+ (xaccSplitSetParent sale-stock sale-txn)
+ (gnc-lot-add-split lot sale-stock)
+ (xaccSplitSetAccount sale-cash cash)
+ (xaccSplitSetAmount sale-cash (gnc-numeric-create 3000 1))
+ (xaccSplitSetValue sale-cash (gnc-numeric-create 3000 1))
+ (xaccSplitSetParent sale-cash sale-txn)
+ (xaccTransCommitEdit sale-txn)
+
+ account-alist)))
+
+(define (create-equivalent-postsplit-sale-baseline-test-data)
+ (let* ((book (gnc-get-current-book))
+ (env (create-test-env))
+ (USD (mnemonic->commodity "USD"))
+ (comm-table (gnc-commodity-table-get-table book))
+ (AAPL (gnc-commodity-new book "Apple" "NASDAQ" "AAPL" "" 1))
+ (structure
+ (list "Root" (list (cons 'type ACCT-TYPE-ASSET)
+ (cons 'commodity USD))
+ (list "Assets"
+ (list "Broker" (list (cons 'commodity USD))
+ (list "Cash" (list (cons 'commodity USD)))
+ (list "AAPL" (list (cons 'commodity AAPL)
+ (cons 'type ACCT-TYPE-STOCK))))))))
+
+ (gnc-commodity-table-insert comm-table AAPL)
+
+ (let* ((account-alist (env-create-account-structure-alist env structure))
+ (cash (cdr (assoc "Cash" account-alist)))
+ (aapl-acct (cdr (assoc "AAPL" account-alist)))
+ (lot (gnc-lot-new book))
+ (buy-txn (xaccMallocTransaction book))
+ (buy-stock (xaccMallocSplit book))
+ (buy-cash (xaccMallocSplit book))
+ (sale-txn (xaccMallocTransaction book))
+ (sale-stock (xaccMallocSplit book))
+ (sale-cash (xaccMallocSplit book)))
+
+ ;; Equivalent post-split basis: buy 50 shares for $5,000.
+ (xaccTransBeginEdit buy-txn)
+ (xaccTransSetCurrency buy-txn USD)
+ (xaccTransSetDate buy-txn 1 1 2020)
+ (xaccTransSetDescription buy-txn "Buy AAPL baseline")
+ (xaccSplitSetAccount buy-stock aapl-acct)
+ (xaccSplitSetAmount buy-stock (gnc-numeric-create 50 1))
+ (xaccSplitSetValue buy-stock (gnc-numeric-create 5000 1))
+ (xaccSplitSetParent buy-stock buy-txn)
+ (gnc-lot-add-split lot buy-stock)
+ (xaccSplitSetAccount buy-cash cash)
+ (xaccSplitSetAmount buy-cash (gnc-numeric-create -5000 1))
+ (xaccSplitSetValue buy-cash (gnc-numeric-create -5000 1))
+ (xaccSplitSetParent buy-cash buy-txn)
+ (xaccTransCommitEdit buy-txn)
+
+ ;; Sell 25 shares for $3,000.
+ (xaccTransBeginEdit sale-txn)
+ (xaccTransSetCurrency sale-txn USD)
+ (xaccTransSetDate sale-txn 1 3 2020)
+ (xaccTransSetDescription sale-txn "Sell AAPL baseline")
+ (xaccSplitSetAccount sale-stock aapl-acct)
+ (xaccSplitSetAmount sale-stock (gnc-numeric-create -25 1))
+ (xaccSplitSetValue sale-stock (gnc-numeric-create -3000 1))
+ (xaccSplitSetParent sale-stock sale-txn)
+ (gnc-lot-add-split lot sale-stock)
+ (xaccSplitSetAccount sale-cash cash)
+ (xaccSplitSetAmount sale-cash (gnc-numeric-create 3000 1))
+ (xaccSplitSetValue sale-cash (gnc-numeric-create 3000 1))
+ (xaccSplitSetParent sale-cash sale-txn)
+ (xaccTransCommitEdit sale-txn)
+
+ account-alist)))
+
+(define (find-col-index labels target)
+ (let loop ((rest labels) (i 1))
+ (cond
+ ((null? rest) #f)
+ ((and (string? (car rest)) (string=? (car rest) target)) i)
+ (else (loop (cdr rest) (+ i 1))))))
+
+(define (list-any pred lst)
+ (if (null? lst)
+ #f
+ (or (pred (car lst))
+ (list-any pred (cdr lst)))))
+
+(define (test-investment-lots)
+ ;; Test rendering with various configurations
+ (test-group-with-cleanup "rendering-tests"
+ (let* ((account-alist (create-investment-lots-test-data)))
+
+ ;; Test 1: Basic rendering with defaults
+ (test-begin "basic-rendering")
+ (let ((options (gnc:make-report-options uuid)))
+ (test-assert "report-renders" (render-report options "default")))
+ (test-end "basic-rendering")
+
+ ;; Test 2: CAGR columns (new in PR #1956)
+ (test-begin "cagr-columns")
+ (let ((options (gnc:make-report-options uuid)))
+ (set-option! options "Columns" "Show CAGR columns" #t)
+ (test-assert "renders-with-cagr" (render-report options "with-cagr")))
+ (test-end "cagr-columns")
+
+ ;; Test 3: Gain columns visibility
+ (test-begin "gain-columns")
+ (let ((options (gnc:make-report-options uuid)))
+ (set-option! options "Columns" "Show realized gain columns" #t)
+ (set-option! options "Columns" "Show unrealized gain columns" #t)
+ (test-assert "renders-gains" (render-report options "with-gains")))
+ (test-end "gain-columns")
+
+ ;; Test 4: LT/ST grouping
+ (test-begin "lt-st-grouping")
+ (let ((options (gnc:make-report-options uuid)))
+ (set-option! options "Columns" "Group gains by age (short term and long term)" 'gains-only)
+ (test-assert "renders-lt-st" (render-report options "with-lt-st")))
+ (test-end "lt-st-grouping")
+
+ ;; Test 5: ROI columns
+ (test-begin "roi-columns")
+ (let ((options (gnc:make-report-options uuid)))
+ (set-option! options "Columns" "Show ROI columns" #t)
+ (test-assert "renders-roi" (render-report options "with-roi")))
+ (test-end "roi-columns")
+
+ ;; Test 6: All column types enabled
+ (test-begin "all-columns-enabled")
+ (let ((options (gnc:make-report-options uuid)))
+ (set-option! options "Columns" "Show bought columns" #t)
+ (set-option! options "Columns" "Show sold columns" #t)
+ (set-option! options "Columns" "Show end columns" #t)
+ (set-option! options "Columns" "Show realized gain columns" #t)
+ (set-option! options "Columns" "Show unrealized gain columns" #t)
+ (set-option! options "Columns" "Show ROI columns" #t)
+ (set-option! options "Columns" "Show CAGR columns" #t)
+ (test-assert "renders-all-columns" (render-report options "all-columns")))
+ (test-end "all-columns-enabled")
+
+ ;; Test 7: Chart display
+ (test-begin "chart-display")
+ (let ((options (gnc:make-report-options uuid)))
+ (set-option! options "Chart" "Show Chart" #t)
+ (test-assert "renders-with-chart" (render-report options "with-chart")))
+ (test-end "chart-display"))
+
+ ;; Cleanup
+ (gnc-clear-current-session))
+
+ ;; Test options integrity
+ (test-group-with-cleanup "options-integrity"
+ (let ((options (gnc:make-report-options uuid)))
+ (test-begin "all-options-exist")
+
+ ;; Verify each option can be set
+ (test-assert "show-purchased"
+ (set-option! options "Columns" "Show bought columns" #t))
+ (test-assert "show-sold"
+ (set-option! options "Columns" "Show sold columns" #t))
+ (test-assert "show-end"
+ (set-option! options "Columns" "Show end columns" #t))
+ (test-assert "show-realized-gains"
+ (set-option! options "Columns" "Show realized gain columns" #t))
+ (test-assert "show-unrealized-gains"
+ (set-option! options "Columns" "Show unrealized gain columns" #t))
+ (test-assert "show-roi"
+ (set-option! options "Columns" "Show ROI columns" #t))
+ (test-assert "show-cagr"
+ (set-option! options "Columns" "Show CAGR columns" #t))
+ (test-assert "group-gains"
+ (set-option! options "Columns" "Group gains by age (short term and long term)" 'gains-and-sales))
+ (test-assert "show-chart"
+ (set-option! options "Chart" "Show Chart" #t))
+ (test-assert "show-validation"
+ (set-option! options "Validation" "Include only accounts with warnings" #t))
+
+ (test-end "all-options-exist"))
+ (gnc-clear-current-session))
+
+ ;; Test different configurations
+ (test-group-with-cleanup "configuration-variants"
+ (let* ((account-alist (create-investment-lots-test-data)))
+
+ (test-begin "column-combinations")
+ (let ((options (gnc:make-report-options uuid)))
+ ;; Test combinations of columns
+ (set-option! options "Columns" "Show realized gain columns" #t)
+ (set-option! options "Columns" "Show ROI columns" #t)
+ (test-assert "realized-gains-and-roi" (render-report options "realized-roi")))
+ (test-end "column-combinations")
+
+ (test-begin "grouping-variants")
+ (let ((opt1 (gnc:make-report-options uuid))
+ (opt2 (gnc:make-report-options uuid))
+ (opt3 (gnc:make-report-options uuid)))
+ ;; Test different grouping options
+ (set-option! opt1 "Columns" "Group gains by age (short term and long term)" 'no)
+ (set-option! opt2 "Columns" "Group gains by age (short term and long term)" 'gains-only)
+ (set-option! opt3 "Columns" "Group gains by age (short term and long term)" 'gains-and-sales)
+ (test-assert "no-grouping" (render-report opt1 "no-grouping"))
+ (test-assert "gains-grouping" (render-report opt2 "gains-grouping"))
+ (test-assert "gains-sales-grouping" (render-report opt3 "gains-sales-grouping")))
+ (test-end "grouping-variants"))
+
+ (gnc-clear-current-session))
+
+ ;; Test calculation functions (new module-level pure functions)
+ (test-group-with-cleanup "calculation-functions"
+ (test-begin "cagr-calculations")
+ ;; Test CAGR calculation with known values
+ ;; $100 â $200 in 1 year = 100% CAGR = 1.0
+ (let* ((basis (gnc-numeric-create 100 1))
+ (end-value (gnc-numeric-create 200 1))
+ (years 1)
+ (result (calculate-cagr basis end-value years)))
+ (test-assert "doubling-in-year" (and result (>= result 0.99) (<= result 1.01))))
+
+ ;; $100 â $100 in 1 year = 0% CAGR = 0.0
+ (let* ((basis (gnc-numeric-create 100 1))
+ (end-value (gnc-numeric-create 100 1))
+ (years 1)
+ (result (calculate-cagr basis end-value years)))
+ (test-assert "zero-growth" (and result (>= result -0.01) (<= result 0.01))))
+
+ ;; Invalid: zero basis
+ (let ((result (calculate-cagr (gnc-numeric-create 0 1)
+ (gnc-numeric-create 100 1) 1)))
+ (test-assert "zero-basis-undefined" (eq? result #f)))
+
+ ;; Invalid: false basis
+ (let ((result (calculate-cagr #f (gnc-numeric-create 100 1) 1)))
+ (test-assert "false-basis-undefined" (eq? result #f)))
+
+ ;; Invalid: negative basis should not produce complex numbers
+ (let ((result (calculate-cagr (gnc-numeric-create -100 1)
+ (gnc-numeric-create 100 1)
+ 1)))
+ (test-assert "negative-basis-undefined" (eq? result #f)))
+
+ ;; Invalid: negative end value should not produce complex numbers
+ (let ((result (calculate-cagr (gnc-numeric-create 100 1)
+ (gnc-numeric-create -50 1)
+ 1)))
+ (test-assert "negative-end-value-undefined" (eq? result #f)))
+
+ ;; $1000 â $1100 in 1 year = 10% CAGR
+ (let* ((basis (gnc-numeric-create 1000 1))
+ (end-value (gnc-numeric-create 1100 1))
+ (years 1)
+ (result (calculate-cagr basis end-value years)))
+ (test-assert "ten-percent-gain" (and result (>= result 0.09) (<= result 0.11))))
+
+ ;; $100 â $110 in 2 years â 4.88% CAGR
+ (let* ((basis (gnc-numeric-create 100 1))
+ (end-value (gnc-numeric-create 110 1))
+ (years 2)
+ (result (calculate-cagr basis end-value years)))
+ (test-assert "110-in-2years" (and result (>= result 0.04) (<= result 0.06))))
+
+ (test-end "cagr-calculations")
+
+ (test-begin "gain-calculations")
+ ;; $100 basis, $150 end value = $50 gain
+ (let* ((basis (gnc-numeric-create 100 1))
+ (end-value (gnc-numeric-create 150 1))
+ (gain (calculate-gain basis end-value)))
+ (test-assert "positive-gain" (not (gnc-numeric-negative-p gain)))
+ (test-assert "fifty-dollar-gain"
+ (gnc-numeric-zero-p
+ (gnc-numeric-sub-fixed gain (gnc-numeric-create 50 1)))))
+
+ ;; $100 basis, $80 end value = -$20 loss
+ (let* ((basis (gnc-numeric-create 100 1))
+ (end-value (gnc-numeric-create 80 1))
+ (loss (calculate-gain basis end-value)))
+ (test-assert "negative-loss" (gnc-numeric-negative-p loss))
+ (test-assert "twenty-dollar-loss"
+ (gnc-numeric-zero-p
+ (gnc-numeric-sub-fixed loss (gnc-numeric-create -20 1)))))
+
+ ;; No change: same value
+ (let* ((basis (gnc-numeric-create 100 1))
+ (end-value (gnc-numeric-create 100 1))
+ (gain (calculate-gain basis end-value)))
+ (test-assert "zero-change" (gnc-numeric-zero-p gain)))
+
+ (test-end "gain-calculations")
+
+ (test-begin "roi-calculations")
+ ;; $100 basis, $25 gain = 25% ROI
+ (let* ((basis (gnc-numeric-create 100 1))
+ (gain (gnc-numeric-create 25 1))
+ (result (calculate-roi basis gain)))
+ (test-assert "positive-roi" (and result (>= result 0.24) (<= result 0.26))))
+
+ ;; $100 basis, -$10 loss = -10% ROI
+ (let* ((basis (gnc-numeric-create 100 1))
+ (loss (gnc-numeric-create -10 1))
+ (result (calculate-roi basis loss)))
+ (test-assert "negative-roi" (and result (<= result -0.09) (>= result -0.11))))
+
+ ;; Invalid: zero basis
+ (let ((result (calculate-roi (gnc-numeric-create 0 1)
+ (gnc-numeric-create 25 1))))
+ (test-assert "zero-basis-roi-undefined" (eq? result #f)))
+
+ ;; $500 basis, $500 gain = 100% ROI
+ (let* ((basis (gnc-numeric-create 500 1))
+ (gain (gnc-numeric-create 500 1))
+ (result (calculate-roi basis gain)))
+ (test-assert "hundred-percent-roi" (and result (>= result 0.99) (<= result 1.01))))
+
+ ;; $200 basis, -$50 loss = -25% ROI
+ (let* ((basis (gnc-numeric-create 200 1))
+ (loss (gnc-numeric-create -50 1))
+ (result (calculate-roi basis loss)))
+ (test-assert "twentyfive-percent-loss" (and result (<= result -0.24) (>= result -0.26))))
+
+ (test-end "roi-calculations")
+
+ (test-begin "long-term-classification")
+ ;; 1 year holding = long-term (exactly at boundary)
+ (let* ((buy-date (gnc-dmy2time64 01 01 2020))
+ (sell-date (gnc-dmy2time64 01 01 2021))
+ (result (is-long-term? buy-date sell-date 1)))
+ (test-assert "one-year-is-lt" result))
+
+ ;; 364 days < 1 year = not long-term (just under)
+ (let* ((buy-date (gnc-dmy2time64 01 01 2020))
+ (sell-date (gnc-dmy2time64 31 12 2020))
+ (result (is-long-term? buy-date sell-date 1)))
+ (test-assert "364-days-not-lt" (not result)))
+
+ ;; 2 year holding with 1-year threshold = long-term
+ (let* ((buy-date (gnc-dmy2time64 01 01 2020))
+ (sell-date (gnc-dmy2time64 01 01 2022))
+ (result (is-long-term? buy-date sell-date 1)))
+ (test-assert "two-years-is-lt" result))
+
+ ;; 5 years holding = long-term
+ (let* ((buy-date (gnc-dmy2time64 01 01 2015))
+ (sell-date (gnc-dmy2time64 01 01 2020))
+ (result (is-long-term? buy-date sell-date 1)))
+ (test-assert "five-years-is-lt" result))
+
+ ;; Same day = not long-term (0 years)
+ (let* ((date (gnc-dmy2time64 01 01 2020))
+ (result (is-long-term? date date 1)))
+ (test-assert "same-day-not-lt" (not result)))
+
+ (test-end "long-term-classification")
+
+ (test-begin "reverse-split-classification")
+ (let ((stock-split-event?
+ (@@ (gnucash reports standard investment-lots)
+ stock-split-event?))
+ (is-non-realizing-share-reduction?
+ (@@ (gnucash reports standard investment-lots)
+ non-realizing-share-reduction?)))
+ ;; Integration check: real engine stock-split metadata must be detected.
+ (let* ((book (gnc-get-current-book))
+ (split (xaccMallocSplit book))
+ (amount (gnc-numeric-create -10 1))
+ (value (gnc-numeric-create 0 1)))
+ (xaccSplitSetAmount split amount)
+ (xaccSplitSetValue split value)
+ (xaccSplitMakeStockSplit split)
+ (xaccSplitSetAction split "Split")
+ (test-assert "engine-stock-split-type-detected"
+ (stock-split-event?
+ (xaccSplitGetType split)
+ (xaccSplitGetAction split)))
+ (test-assert "engine-reverse-split-classified"
+ (is-non-realizing-share-reduction?
+ (xaccSplitGetType split)
+ (xaccSplitGetAction split)
+ amount
+ value)))
+
+ ;; Reverse split style reduction should be treated as non-realizing.
+ (test-assert "stock-split-negative-zero-value"
+ (is-non-realizing-share-reduction?
+ "stock-split"
+ "Split"
+ (gnc-numeric-create -10 1)
+ (gnc-numeric-create 0 1)))
+
+ ;; Ordinary sale with zero proceeds is not auto-classified as split.
+ (test-assert "plain-sale-zero-value-not-split"
+ (not (is-non-realizing-share-reduction?
+ ""
+ ""
+ (gnc-numeric-create -10 1)
+ (gnc-numeric-create 0 1))))
+
+ ;; Forward split increases shares and is not a reduction event.
+ (test-assert "forward-split-not-reduction"
+ (not (is-non-realizing-share-reduction?
+ "stock-split"
+ "Split"
+ (gnc-numeric-create 10 1)
+ (gnc-numeric-create 0 1))))
+
+ ;; Matrix-style coverage on reduction detection gates used by split
+ ;; classification.
+ (test-assert "matrix-positive-amount-not-reduction"
+ (not (is-non-realizing-share-reduction?
+ ""
+ ""
+ (gnc-numeric-create 10 1)
+ (gnc-numeric-create 100 1))))
+ (test-assert "matrix-sale-with-proceeds-not-reduction"
+ (not (is-non-realizing-share-reduction?
+ ""
+ ""
+ (gnc-numeric-create -10 1)
+ (gnc-numeric-create -100 1))))
+ (test-assert "matrix-zero-amount-not-reduction"
+ (not (is-non-realizing-share-reduction?
+ ""
+ ""
+ (gnc-numeric-create 0 1)
+ (gnc-numeric-create 100 1))))
+ (test-assert "matrix-reduction-detected"
+ (is-non-realizing-share-reduction?
+ "stock-split"
+ "Split"
+ (gnc-numeric-create -10 1)
+ (gnc-numeric-create 0 1))))
+
+ (test-end "reverse-split-classification")
+
+ (gnc-clear-current-session))
+
+ ;; End-to-end rendered output regression for reverse split behavior.
+ (test-group-with-cleanup "reverse-split-render-regression"
+ (let* ((account-alist (create-reverse-split-lot-test-data))
+ (options (gnc:make-report-options uuid))
+ (sxml (options->sxml options "reverse-split-regression"))
+ (headers (sxml->table-row-col sxml 1 0 #f))
+ (sold-col (find-col-index headers "Sold Splits"))
+ (realized-col (find-col-index headers "Realized Gain")))
+
+ (test-assert "found-sold-splits-column" sold-col)
+ (test-assert "realized-gain-column-optional-when-no-sales" #t)
+
+ (let ((sold-values (sxml->table-row-col sxml 1 #f sold-col))
+ (realized-values (if realized-col
+ (sxml->table-row-col sxml 1 #f realized-col)
+ '())))
+ ;; Reverse split must not be counted as a sold split.
+ (test-assert "no-sold-split-count-one"
+ (not (list-any (lambda (v) (and (string? v) (string=? v "1")))
+ sold-values)))
+
+ ;; Reverse split must not produce phantom realized loss formatting.
+ (test-assert "no-phantom-negative-realized-loss"
+ (not (list-any (lambda (v)
+ (and (string? v)
+ (string-contains v "(")))
+ realized-values)))))
+
+ (gnc-clear-current-session))
+
+ ;; Compare baseline vs reverse-split render to ensure no phantom sale/gain.
+ (test-group-with-cleanup "reverse-split-vs-baseline-regression"
+ (let* ((base-alist (create-baseline-lot-test-data))
+ (base-options (gnc:make-report-options uuid))
+ (base-sxml (options->sxml base-options "baseline-lot"))
+ (base-headers (sxml->table-row-col base-sxml 1 0 #f))
+ (base-sold-col (find-col-index base-headers "Sold Splits"))
+ (base-realized-col (find-col-index base-headers "Realized Gain"))
+ (base-sold-values (if base-sold-col
+ (sxml->table-row-col base-sxml 1 #f base-sold-col)
+ '()))
+ (base-realized-values (if base-realized-col
+ (sxml->table-row-col base-sxml 1 #f base-realized-col)
+ '())))
+
+ (gnc-clear-current-session)
+
+ (let* ((rev-alist (create-reverse-split-lot-test-data))
+ (rev-options (gnc:make-report-options uuid))
+ (rev-sxml (options->sxml rev-options "reverse-split-vs-baseline"))
+ (rev-headers (sxml->table-row-col rev-sxml 1 0 #f))
+ (rev-sold-col (find-col-index rev-headers "Sold Splits"))
+ (rev-realized-col (find-col-index rev-headers "Realized Gain"))
+ (rev-sold-values (if rev-sold-col
+ (sxml->table-row-col rev-sxml 1 #f rev-sold-col)
+ '()))
+ (rev-realized-values (if rev-realized-col
+ (sxml->table-row-col rev-sxml 1 #f rev-realized-col)
+ '())))
+
+ (test-assert "sold-splits-equal-baseline-vs-reverse"
+ (equal? base-sold-values rev-sold-values))
+ (test-assert "realized-gain-equal-baseline-vs-reverse"
+ (equal? base-realized-values rev-realized-values))))
+
+ (gnc-clear-current-session))
+
+ ;; Reverse-split-with-sale should match equivalent post-split baseline.
+ (test-group-with-cleanup "reverse-split-with-sale-equivalence"
+ (let* ((base-alist (create-equivalent-postsplit-sale-baseline-test-data))
+ (base-options (gnc:make-report-options uuid))
+ (base-sxml (options->sxml base-options "equivalent-baseline-sale"))
+ (base-headers (sxml->table-row-col base-sxml 1 0 #f))
+ (base-sold-col (find-col-index base-headers "Sold Splits"))
+ (base-realized-col (find-col-index base-headers "Realized Gain"))
+ (base-sold-values (if base-sold-col
+ (sxml->table-row-col base-sxml 1 #f base-sold-col)
+ '()))
+ (base-realized-values (if base-realized-col
+ (sxml->table-row-col base-sxml 1 #f base-realized-col)
+ '())))
+
+ (gnc-clear-current-session)
+
+ (let* ((rev-alist (create-reverse-split-with-sale-test-data))
+ (rev-options (gnc:make-report-options uuid))
+ (rev-sxml (options->sxml rev-options "reverse-split-with-sale"))
+ (rev-headers (sxml->table-row-col rev-sxml 1 0 #f))
+ (rev-sold-col (find-col-index rev-headers "Sold Splits"))
+ (rev-realized-col (find-col-index rev-headers "Realized Gain"))
+ (rev-sold-values (if rev-sold-col
+ (sxml->table-row-col rev-sxml 1 #f rev-sold-col)
+ '()))
+ (rev-realized-values (if rev-realized-col
+ (sxml->table-row-col rev-sxml 1 #f rev-realized-col)
+ '())))
+
+ (test-assert "reverse-split-sale-has-sold-splits-column" rev-sold-col)
+ (test-assert "reverse-split-sale-realized-gain-column-optional" #t)
+ (test-assert "sold-splits-equal-equivalent-baseline"
+ (equal? base-sold-values rev-sold-values))
+ (test-assert "realized-gain-equal-equivalent-baseline"
+ (equal? base-realized-values rev-realized-values))))
+
+ (gnc-clear-current-session)))
Summary of changes:
.../report/reports/standard/investment-lots.scm | 814 +++++++++++++-------
.../report/reports/standard/test/CMakeLists.txt | 1 +
.../reports/standard/test/test-investment-lots.scm | 836 +++++++++++++++++++++
3 files changed, 1395 insertions(+), 256 deletions(-)
create mode 100644 gnucash/report/reports/standard/test/test-investment-lots.scm
More information about the gnucash-changes
mailing list