Skip to content

fix(holdings): use trade-date FX rate for cost basis price conversion#2014

Open
dale053 wants to merge 10 commits into
we-promise:mainfrom
dale053:fix/2012-cost-basis-fx-trade-date-rate
Open

fix(holdings): use trade-date FX rate for cost basis price conversion#2014
dale053 wants to merge 10 commits into
we-promise:mainfrom
dale053:fix/2012-cost-basis-fx-trade-date-rate

Conversation

@dale053

@dale053 dale053 commented May 27, 2026

Copy link
Copy Markdown

Summary

Fixes #2012

Cost basis for foreign-currency trades was being converted at today's exchange rate instead of the rate on the trade date, causing cost basis to drift as FX rates move over time.

  • ForwardCalculator and ReverseCalculator both called trade_price.exchange_to(account.currency) with no date context, so Money resolved the conversion using the most recent available rate.
  • Both calculators now pass date: trade_entry.date and custom_rate: trade.exchange_rate to Money#exchange_to, pinning conversion to the trade date and preferring any provider-supplied rate (e.g. from IBKR/Plaid) over a DB lookup.

Changes

  • app/models/holding/forward_calculator.rb — use trade-date rate for cost basis price conversion
  • app/models/holding/reverse_calculator.rb — same fix
  • test/models/holding/forward_calculator_test.rb — two new tests: trade-date DB rate and provider-supplied rate
  • test/models/holding/reverse_calculator_test.rb — same two tests for the reverse path
  • test/support/entries_test_helper.rb — expose exchange_rate: kwarg on create_trade helper

Test plan

  • bin/rails test test/models/holding/forward_calculator_test.rb
  • bin/rails test test/models/holding/reverse_calculator_test.rb
  • bin/rails test (full suite)
  • bin/rubocop -f github -a
  • bin/brakeman --no-pager

Summary by CodeRabbit

  • Bug Fixes

    • Foreign-currency cost basis now uses the trade-date exchange rate; provider-supplied rates override stored rates. If FX conversion fails, the affected trade is skipped, a warning is logged, and subsequent trades for that security are ignored to avoid mixing currencies.
  • Tests

    • Added tests for trade-date FX, provider-supplied rates, FX-conversion failures, and exclusion behavior. Test helper accepts an explicit exchange-rate parameter.

Review Change Stack

@superagent-security superagent-security Bot added the contributor:verified Contributor passed trust analysis. label May 27, 2026
@superagent-security superagent-security Bot added the pr:verified PR passed security analysis. label May 27, 2026
@coderabbitai

coderabbitai Bot commented May 27, 2026

Copy link
Copy Markdown
Contributor

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Both holding calculators now convert trade prices into account currency using the trade date and an optional provider-supplied exchange_rate; on conversion failure calculators log a warning and skip the trade. Tests and the test helper were updated accordingly.

Changes

Trade-date FX conversion for cost basis

Layer / File(s) Summary
Calculator FX conversion updates
app/models/holding/forward_calculator.rb, app/models/holding/reverse_calculator.rb
Both calculators updated to call Money#exchange_to with trade date (trade_entry.date) and custom rate (trade.exchange_rate); on Money::ConversionError they log and skip the trade. ReverseCalculator adds a per-security invalid_cost_basis guard to stop further tracking after the first conversion failure.
Test helper exchange_rate parameter
test/support/entries_test_helper.rb
create_trade signature extended with optional exchange_rate: keyword argument; helper conditionally assigns the rate to created trades when provided.
Forward calculator FX behavior tests
test/models/holding/forward_calculator_test.rb
Adds three tests: trade-date FX rate usage for foreign-currency buy, conversion-failure skipping (cost_basis nil), and provider-supplied exchange_rate precedence.
Reverse calculator FX behavior tests
test/models/holding/reverse_calculator_test.rb
Adds four tests: trade-date FX usage, provider-supplied exchange_rate precedence, conversion-failure nil snapshots, and exclusion resulting in cost_basis = nil for affected holdings.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

  • we-promise/sure#1579: Both PRs modify Holding::ReverseCalculator’s cost-basis precomputation/lookup path and may overlap in snapshot/precomputation logic.

Suggested labels

contributor:verified, pr:verified

Suggested reviewers

  • jjmata
  • EdeAbreu23

Poem

🐰 I hopped through ledgers, date by date,

old euros converted where history waits.
When rates go missing I log and I skip,
keeping totals honest — no currency slip.
A tiny hop for books that now balance straight.

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Linked Issues check ⚠️ Warning The PR partially addresses linked issue #2012 by passing trade-date to FX conversion, but does not fully implement all acceptance criteria: shared conversion helper is missing, backfill enqueuing and cost-basis invalidation are not complete, and ExchangeRate::Importer changes are not included. Complete the remaining acceptance criteria: implement a single shared conversion helper, add backfill enqueuing on conversion errors, update ExchangeRate::Importer to cover earliest trade dates, and handle cost-basis reconciliation for existing holdings.
Docstring Coverage ⚠️ Warning Docstring coverage is 16.67% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly describes the main fix: using trade-date FX rates for cost basis price conversion, which directly addresses the core issue being fixed.
Out of Scope Changes check ✅ Passed All changes are directly related to the cost-basis FX conversion fix: the two calculators, their tests, and the test helper to support exchange_rate parameters align with the 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/forward_calculator.rb`:
- Around line 90-96: The rescue for Money::ConversionError currently falls back
to trade.price and can mix currencies; change it to NOT silently convert: in the
rescue block log the conversion failure (include trade.id, trade.currency,
account.currency, trade_entry.date, trade.exchange_rate), enqueue an FX backfill
job (e.g. call your existing FxRateBackfillJob/FXBackfillWorker with
trade.currency, account.currency, trade_entry.date) and set converted_price =
nil (or return nil/skip this trade from the surrounding method) so the holding
cost is not updated; keep Money::ConversionError as the exception to catch and
ensure the method that uses converted_price correctly handles a nil value.
🪄 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: d29cd504-aa03-4ab4-883c-0ec91e42600a

📥 Commits

Reviewing files that changed from the base of the PR and between 0342958 and 0e30b3d.

📒 Files selected for processing (5)
  • app/models/holding/forward_calculator.rb
  • app/models/holding/reverse_calculator.rb
  • test/models/holding/forward_calculator_test.rb
  • test/models/holding/reverse_calculator_test.rb
  • test/support/entries_test_helper.rb

Comment thread app/models/holding/forward_calculator.rb
@superagent-security superagent-security Bot removed the contributor:verified Contributor passed trust analysis. label May 27, 2026
@superagent-security superagent-security Bot removed the pr:verified PR passed security analysis. label May 27, 2026

@jjmata jjmata left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

The core fix (passing date: trade_entry.date, custom_rate: trade.exchange_rate to exchange_to) is correct and well-motivated. Tests are comprehensive — trade-date DB rate, provider-supplied override, and failure path are all covered.

One inconsistency between the two calculators worth flagging:

ForwardCalculator (correct) — on Money::ConversionError, uses next to skip the trade entirely, resulting in nil cost basis. The warning log and the test "cost_basis is nil and trade is skipped when FX conversion fails" both document this intentional behavior.

ReverseCalculator (unchanged fallback) — on Money::ConversionError, still falls back to converted_price = trade.price, using the raw numeric price without currency conversion. For a 10-share EUR trade at €100, this silently produces a cost basis of $100 USD instead of nil, mixing currencies with no warning.

The PR description says "If FX conversion fails, the trade is excluded from cost-basis (resulting in nil)" — that describes ForwardCalculator only. ReverseCalculator still takes the old silent-fallback path, and there's no corresponding "cost_basis is nil and trade is skipped when FX conversion fails" test for the reverse path.

Suggest aligning ReverseCalculator's rescue branch to also use next (and optionally logging the same warning), so both calculators produce consistent output and the PR description accurately reflects the full change.


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.

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 94-101: The rescue for Money::ConversionError currently does
"next", which only skips the failed trade and allows later snapshots to be built
for the same security; change the behavior so that once an FX conversion fails
for a security we stop computing any further snapshots for that security and
cause cost_basis_for to return nil for subsequent dates. Locate the rescue block
in Holding::ReverseCalculator (the one referencing trade, trade_entry and
trade.exchange_rate), and replace the "next" with logic that halts snapshot
construction for this security — e.g. set a flag or mark the security as invalid
for FX in the calculator's state (or break/return out of the snapshot-building
loop) so that cost_basis_for detects the flag and returns nil for later dates
instead of continuing to compute averages.
🪄 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: 3bf4a02d-423b-44bf-b9dd-b950fcd2e0ad

📥 Commits

Reviewing files that changed from the base of the PR and between 23422ea and 1822d33.

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

Comment thread app/models/holding/reverse_calculator.rb

@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.

🧹 Nitpick comments (1)
test/models/holding/reverse_calculator_test.rb (1)

295-326: ⚡ Quick win

Consider verifying intermediate holdings for more comprehensive test coverage.

The test correctly validates that cost_basis is nil after an FX conversion failure persists even when later trades succeed. However, it only asserts the final state (today's holding). To more thoroughly verify the "invalidate all dates after failure" behavior, consider adding assertions for:

  1. Holdings on first_buy date (5/20) should have valid cost_basis (≈110.0 = 100 EUR × 1.10)
  2. Holdings on/after failed_buy date (5/27, 5/30) should have nil cost_basis
  3. The today holding still has correct qty and amount despite nil cost_basis

This would provide stronger evidence that the invalidation happens precisely at the failure point and propagates forward correctly.

🧪 Proposed enhancement with intermediate assertions
 snapshot = OpenStruct.new(to_h: { eur_stock.id => 30 })
 calculated = Holding::ReverseCalculator.new(`@account`, portfolio_snapshot: snapshot).calculate

+first_buy_holding = calculated.find { |h| h.date == first_buy && h.security == eur_stock }
+assert_in_delta 110.0, first_buy_holding.cost_basis.to_f, 0.01,
+  "cost_basis should be valid (110 USD) before the FX failure on #{failed_buy}"
+
+failed_date_holding = calculated.find { |h| h.date == failed_buy && h.security == eur_stock }
+assert_nil failed_date_holding.cost_basis,
+  "cost_basis should be nil starting from the FX failure date"
+
+later_date_holding = calculated.find { |h| h.date == later_buy && h.security == eur_stock }
+assert_nil later_date_holding.cost_basis,
+  "cost_basis should remain nil even for dates with successful conversions after the failure"
+
 today_holding = calculated.find { |h| h.date == Date.current && h.security == eur_stock }
+assert_equal 30, today_holding.qty, "qty should still be calculated despite nil cost_basis"
 assert_nil today_holding.cost_basis,
   "cost_basis must be nil after FX failure even when a later buy converts successfully"
🤖 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 `@test/models/holding/reverse_calculator_test.rb` around lines 295 - 326, Add
intermediate assertions to the test to verify holdings around the FX failure
point: after creating prices, exchange rates, trades and computing calculated =
Holding::ReverseCalculator.new(`@account`, portfolio_snapshot:
snapshot).calculate, locate holdings by date/security (use snapshot and
eur_stock) and assert that the holding on first_buy has a non-nil cost_basis ≈
100 * 1.10, the holdings on failed_buy and later_buy (and Date.current) have nil
cost_basis, and that today_holding (found as the element where h.date ==
Date.current && h.security == eur_stock) still has the expected qty and amount
despite cost_basis being nil; reference methods/objects:
Holding::ReverseCalculator, create_trade, ExchangeRate, Security::Price,
today_holding and snapshot to find and assert the intermediate values.
🤖 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.

Nitpick comments:
In `@test/models/holding/reverse_calculator_test.rb`:
- Around line 295-326: Add intermediate assertions to the test to verify
holdings around the FX failure point: after creating prices, exchange rates,
trades and computing calculated = Holding::ReverseCalculator.new(`@account`,
portfolio_snapshot: snapshot).calculate, locate holdings by date/security (use
snapshot and eur_stock) and assert that the holding on first_buy has a non-nil
cost_basis ≈ 100 * 1.10, the holdings on failed_buy and later_buy (and
Date.current) have nil cost_basis, and that today_holding (found as the element
where h.date == Date.current && h.security == eur_stock) still has the expected
qty and amount despite cost_basis being nil; reference methods/objects:
Holding::ReverseCalculator, create_trade, ExchangeRate, Security::Price,
today_holding and snapshot to find and assert the intermediate values.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: f0133b86-aae0-4aeb-a88c-c4e739c25cb9

📥 Commits

Reviewing files that changed from the base of the PR and between 1822d33 and f9d8411.

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

@coderabbitai

coderabbitai Bot commented May 28, 2026

Copy link
Copy Markdown
Contributor

Actionable comments posted: 0

@jjmata jjmata left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Can you update the Rails.log(...) statements to use the new Debug log at https://app.sure.am/settings/debug? Much cleaner and let's people see those without opening the server logs!

@dale053

dale053 commented Jun 1, 2026

Copy link
Copy Markdown
Author

Hi, @jjmata
Could you please review this PR?

Copy link
Copy Markdown
Collaborator

Noticed the check runs on the current head commit (8d146b0) only show "Superagent Security Scan" and "Contributor trust" (both from June 1) — no ci / test_unit, ci / lint, ci / scan_ruby, etc. have run despite 10 commits since. The full test/lint/security suite doesn't appear to have executed on this PR at all, which is easy to miss since the PR shows as "blocked" rather than failing. Might be stuck on workflow-run approval for an external contributor — worth checking before merge given this changes cost-basis FX calculation.


Generated by Claude Code

Copy link
Copy Markdown
Collaborator

No commits since May 28 addressed @jjmata's June 1 request to route the Rails.log(...) warnings (on FX conversion failure) through DebugLogEntry.capture, per the repo's debug-logging convention for sync/holding issues. Flagging so it doesn't get lost — the core trade-date FX fix and test coverage otherwise look solid.


Generated by Claude Code

Copy link
Copy Markdown
Collaborator

Automated review sweep: the fix and test coverage are solid and the first round of feedback was fully addressed same-day. Two things are still open: the 06-01 ask to route Rails.logger calls through DebugLogEntry.capture per CLAUDE.md (unaddressed 6+ weeks), and the full CI suite hasn't run yet — only trust/security-scan checks are present, likely stuck on workflow approval for an external contributor.


Generated by Claude Code

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

Labels

None yet

Projects

Development

Successfully merging this pull request may close these issues.

Bug: Foreign-currency cost basis converted at today's FX rate, not the trade-date rate

4 participants