Skip to content

Fix holding cost basis to relieve sells under weighted-average method#2087

Open
philluiz2323 wants to merge 2 commits into
we-promise:mainfrom
philluiz2323:fix/holding-cost-basis-sell-relief
Open

Fix holding cost basis to relieve sells under weighted-average method#2087
philluiz2323 wants to merge 2 commits into
we-promise:mainfrom
philluiz2323:fix/holding-cost-basis-sell-relief

Conversation

@philluiz2323

@philluiz2323 philluiz2323 commented May 31, 2026

Copy link
Copy Markdown

Fixes #2086.

Summary

The holding cost-basis calculators track a weighted average but only count buy trades — sells never relieve the running tracker (next unless trade.qty > 0). Cost basis is therefore averaged over every buy the account ever made for a security, including shares already sold. After a position is fully sold and later repurchased, the old (realized) lots stay in the denominator and corrupt the cost basis of the currently-held lot, which feeds Holding#avg_cost (holdings table, holding page, API avg_cost, and unrealized gain/loss).

Example: buy 10 @ $100, sell all 10 @ $300, re-buy 10 @ $300 -> reported cost basis $200/share instead of the correct $300, overstating unrealized gain by $1,000.

Changes

  • Add Holding::CostBasisTracker (PORO) encapsulating the weighted-average method: buys raise the basis, sells relieve quantity at the running average (leaving the per-share average unchanged), and a full liquidation resets the basis so a later repurchase starts clean. Guards against over-selling.
  • Holding::ForwardCalculator#update_cost_basis_tracker now applies every trade (buys and sells) through the tracker.
  • Holding::ReverseCalculator#precompute_cost_basis uses the tracker and snapshots cost basis only while a position is held.
  • Tests: a unit test for the tracker (buy/sell/relief/reset/over-sell) and a ForwardCalculator regression test covering buy -> sell-all -> re-buy.

Why this is safe (no regression on existing holdings)

Relieving a sell at the running average leaves the per-share average unchanged for a simple partial trim, so positions that were never fully closed are unaffected. Only the sold-off-and-repurchased case changes — to the correct value. The existing forward portfolio calculation test (which asserts qty/price/amount) is unaffected.

Scope note: the Holding#calculate_avg_cost SQL fallback (used only for holdings without a pre-computed cost basis) shares the same buys-only limitation, but is intentionally left out of this PR to keep it orthogonal to the separate FX-rate concern tracked in #2012/#2014.

Testing

bin/rails test test/models/holding/cost_basis_tracker_test.rb test/models/holding/forward_calculator_test.rb

Summary by CodeRabbit

  • New Features

    • Enhanced weighted-average cost-basis tracking for securities with correct reset on full liquidation and accurate repurchase handling.
    • Improved trade price conversion with safe fallback for multi-currency scenarios.
  • Tests

    • Added comprehensive tests covering liquidation, repurchase, oversell protection, and float-input edge cases.

@coderabbitai

coderabbitai Bot commented May 31, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

This PR introduces Holding::CostBasisTracker, a stateful class that correctly computes weighted-average cost basis by applying both buy and sell trades. Sells now relieve quantity at the current average cost and full liquidation resets the basis. ForwardCalculator and ReverseCalculator are refactored to use trackers instead of manual accumulation, with currency conversion centralized in a shared helper. Tests validate the tracker logic and confirm the fix prevents cost basis corruption after a position is fully sold and repurchased.

Changes

Cost Basis Tracking Fix

Layer / File(s) Summary
CostBasisTracker class and logic
app/models/holding/cost_basis_tracker.rb
New class tracks running totals for cost and quantity. apply(price, qty) routes trades by sign: buy increases totals, sell relieves at average cost and resets on full liquidation. average_cost returns weighted average or nil when no position exists.
CostBasisTracker test suite
test/models/holding/cost_basis_tracker_test.rb
Tests validate nil initial average, weighted-average after buys, unchanged per-share average after partial sell, nil after full liquidation with repurchase recomputation, oversell protection, and float-quantity liquidation edge-case.
ForwardCalculator migrates to CostBasisTracker
app/models/holding/forward_calculator.rb
Initialization uses per-security CostBasisTracker instances. update_cost_basis_tracker applies all trades (not just buys) via converted_trade_price(trade); cost_basis_for returns the tracker's average_cost.
ReverseCalculator migrates to CostBasisTracker
app/models/holding/reverse_calculator.rb
precompute_cost_basis uses per-security trackers, applies converted trades in date order, and records [date, average_cost] snapshots when present. The file includes the shared TradeCalculatorHelpers.
Trade price conversion helper
app/models/holding/trade_calculator_helpers.rb
Adds converted_trade_price(trade) to centralize Money-based currency conversion with a Money::ConversionError fallback to the raw trade price.
ForwardCalculator regression test for sell-and-repurchase
test/models/holding/forward_calculator_test.rb
New regression test ensures cost basis resets after a full sell and later repurchase so the basis reflects only the new lot.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related issues

Possibly related PRs

Suggested labels

contributor:verified, pr:verified

Suggested reviewers

  • jjmata
  • EdeAbreu23

Poem

🐰 I tally buys and I tally sells, in tidy BigDecimal rows,
When everything is zeroed, my little tracker humbly knows.
Repurchase sings anew, no ghosts from days before,
The ledger stays honest — hop! — and errors haunt no more.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 33.33% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately describes the main change: fixing cost-basis calculation to account for sell transactions under weighted-average method.
Linked Issues check ✅ Passed All objectives from #2086 are met: sells are now applied to the tracker [2086], quantity is relieved at running average [2086], full liquidation resets the tracker [2086], over-selling is guarded against [2086], and cost basis is corrected for sold-and-repurchased scenarios [2086].
Out of Scope Changes check ✅ Passed All changes are directly scoped to the objectives: CostBasisTracker implements weighted-average logic, ForwardCalculator and ReverseCalculator now apply all trades, trade-price helper is extracted, and tests cover the fix. The fallback Holding#calculate_avg_cost was intentionally left out of scope per PR objectives.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@app/models/holding/reverse_calculator.rb`:
- Around line 86-97: The current loop in reverse_calculator.rb uses
portfolio_cache.get_trades.sort_by(&:date) which leaves same-day trade ordering
ambiguous and can snapshot the wrong end-of-day basis; change the sort so it
deterministically orders trades within a date using an intra-day tiebreaker from
the trade_entry or trade (e.g., execution timestamp, sequence number, or id)
before applying tracker.apply and capturing
`@cost_basis_snapshots`[trade.security_id]; update the sort expression in the
block that iterates over portfolio_cache.get_trades to sort by [date,
tie_breaker] (using trade_entry.timestamp / trade.executed_at /
trade_entry.sequence / trade.id as available) so same-day sell/all + repurchase
sequences are processed in a stable, deterministic order.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: bf3ebf55-2757-422b-b723-fb3f4802017c

📥 Commits

Reviewing files that changed from the base of the PR and between 3311759 and ca17fb8.

📒 Files selected for processing (5)
  • app/models/holding/cost_basis_tracker.rb
  • app/models/holding/forward_calculator.rb
  • app/models/holding/reverse_calculator.rb
  • test/models/holding/cost_basis_tracker_test.rb
  • test/models/holding/forward_calculator_test.rb

Comment on lines 86 to +97
portfolio_cache.get_trades.sort_by(&:date).each do |trade_entry|
trade = trade_entry.entryable
next unless trade.qty > 0
tracker = trackers[trade.security_id]

security_id = trade.security_id
trade_price = Money.new(trade.price, trade.currency)
begin
converted_price = trade_price.exchange_to(account.currency).amount
rescue Money::ConversionError
converted_price = trade.price
end
# Buys raise the basis; sells relieve quantity at the running average so
# the figure stays correct after a position is fully sold and repurchased.
tracker.apply(converted_trade_price(trade), trade.qty)

tracker[security_id][:total_cost] += converted_price * trade.qty
tracker[security_id][:total_qty] += trade.qty
average_cost = tracker.average_cost
next if average_cost.nil?

@cost_basis_snapshots[security_id] << [
trade_entry.date,
tracker[security_id][:total_cost] / tracker[security_id][:total_qty]
]
@cost_basis_snapshots[trade.security_id] << [ trade_entry.date, average_cost ]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Make same-day trade ordering explicit before snapshotting.

Now that sells mutate the running tracker, replay order matters. sort_by(&:date) leaves same-day buy/sell sequences ambiguous, so a sell-all + repurchase on one date can still snapshot the wrong end-of-day basis unless this loop uses a deterministic intra-day tiebreaker from trade_entry/trade (execution timestamp, sequence, id, etc.).

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/models/holding/reverse_calculator.rb` around lines 86 - 97, The current
loop in reverse_calculator.rb uses portfolio_cache.get_trades.sort_by(&:date)
which leaves same-day trade ordering ambiguous and can snapshot the wrong
end-of-day basis; change the sort so it deterministically orders trades within a
date using an intra-day tiebreaker from the trade_entry or trade (e.g.,
execution timestamp, sequence number, or id) before applying tracker.apply and
capturing `@cost_basis_snapshots`[trade.security_id]; update the sort expression
in the block that iterates over portfolio_cache.get_trades to sort by [date,
tie_breaker] (using trade_entry.timestamp / trade.executed_at /
trade_entry.sequence / trade.id as available) so same-day sell/all + repurchase
sequences are processed in a stable, deterministic order.

jjmata commented Jun 1, 2026

Copy link
Copy Markdown
Collaborator

Clean abstraction — CostBasisTracker as a standalone PORO makes the weighted-average logic easy to test and reason about in isolation. The reset-on-full-liquidation fix is exactly right.

Two notes:

  1. Duplicated helper. converted_trade_price is now identical in both ForwardCalculator and ReverseCalculator. Worth extracting to a shared private module (Holding::TradeCalculatorHelpers or similar) before more calculators appear, but not a blocker.

  2. Quantity type. The tracker uses BigDecimal arithmetic internally and the tests pass BigDecimal directly. It's worth confirming that trade quantities coming out of the DB (and through trade.qty) reach the tracker as BigDecimal (or are coerced before being passed to apply). If qty arrives as a Float, the [ qty.abs, @total_qty ].min guard and the subsequent subtraction could accumulate floating-point error and leave @total_qty as a very small negative rather than exactly zero, bypassing the reset call.


Generated by Claude Code

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
app/models/holding/reverse_calculator.rb (1)

96-99: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Record liquidation resets in the snapshot history.

Skipping the append on Line 97 when average_cost becomes nil means cost_basis_for falls back to the last pre-liquidation value for later dates. Because build_holdings still emits zero-qty rows, sold-out days inherit a stale basis instead of nil. Writing a reset snapshot here, or filtering zero-qty holdings, would keep the reverse path aligned with the forward tracker behavior.

Based on learnings "store cost_basis on Holding as a per-share value (not total position cost)."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/models/holding/reverse_calculator.rb` around lines 96 - 99, When
tracker.average_cost becomes nil on liquidation, you must record a reset
snapshot instead of skipping it so cost_basis_for doesn't reuse stale values; in
reverse_calculator.rb update the block that currently does "average_cost =
tracker.average_cost / next if average_cost.nil?" to always push a snapshot for
trade.security_id using trade_entry.date with nil (or an explicit reset marker)
when average_cost is nil, ensuring `@cost_basis_snapshots`[trade.security_id] <<
[trade_entry.date, average_cost_or_nil] is written in both cases; alternatively,
if you prefer filtering, adjust build_holdings to skip zero-qty Holding rows so
sold-out days aren’t given a carried-forward basis (reference symbols:
tracker.average_cost, `@cost_basis_snapshots`, trade_entry.date, cost_basis_for,
build_holdings, Holding).
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@app/models/holding/reverse_calculator.rb`:
- Around line 96-99: When tracker.average_cost becomes nil on liquidation, you
must record a reset snapshot instead of skipping it so cost_basis_for doesn't
reuse stale values; in reverse_calculator.rb update the block that currently
does "average_cost = tracker.average_cost / next if average_cost.nil?" to always
push a snapshot for trade.security_id using trade_entry.date with nil (or an
explicit reset marker) when average_cost is nil, ensuring
`@cost_basis_snapshots`[trade.security_id] << [trade_entry.date,
average_cost_or_nil] is written in both cases; alternatively, if you prefer
filtering, adjust build_holdings to skip zero-qty Holding rows so sold-out days
aren’t given a carried-forward basis (reference symbols: tracker.average_cost,
`@cost_basis_snapshots`, trade_entry.date, cost_basis_for, build_holdings,
Holding).

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 00446fc5-5d8b-4a80-9bfe-c12cdf42c2a1

📥 Commits

Reviewing files that changed from the base of the PR and between ca17fb8 and eb6a3a2.

📒 Files selected for processing (5)
  • app/models/holding/cost_basis_tracker.rb
  • app/models/holding/forward_calculator.rb
  • app/models/holding/reverse_calculator.rb
  • app/models/holding/trade_calculator_helpers.rb
  • test/models/holding/cost_basis_tracker_test.rb
🚧 Files skipped from review as they are similar to previous changes (2)
  • test/models/holding/cost_basis_tracker_test.rb
  • app/models/holding/cost_basis_tracker.rb

@philluiz2323

Copy link
Copy Markdown
Author

Clean abstraction — CostBasisTracker as a standalone PORO makes the weighted-average logic easy to test and reason about in isolation. The reset-on-full-liquidation fix is exactly right.

Two notes:

  1. Duplicated helper. converted_trade_price is now identical in both ForwardCalculator and ReverseCalculator. Worth extracting to a shared private module (Holding::TradeCalculatorHelpers or similar) before more calculators appear, but not a blocker.
  2. Quantity type. The tracker uses BigDecimal arithmetic internally and the tests pass BigDecimal directly. It's worth confirming that trade quantities coming out of the DB (and through trade.qty) reach the tracker as BigDecimal (or are coerced before being passed to apply). If qty arrives as a Float, the [ qty.abs, @total_qty ].min guard and the subsequent subtraction could accumulate floating-point error and leave @total_qty as a very small negative rather than exactly zero, bypassing the reset call.

Generated by Claude Code

Thanks @jjmata — both addressed in eb6a3a2:

Extracted converted_trade_price into a shared Holding::TradeCalculatorHelpers module, now included by both calculators.
trades.qty is decimal(24,8) so it arrives as BigDecimal in practice, but you're right a Float would be unsafe — CostBasisTracker#apply/#buy/#sell now coerce via .to_d, so a full liquidation lands on exactly zero and fires the reset. Added a regression test that runs the buy → sell-all → re-buy cycle with Float quantities.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Bug: Holding cost basis ignores sells — average cost is corrupted after a position is fully sold and repurchased

2 participants