Missing exchange rates are converted at 1:1, so a ¥1,000,000 gain reads as $1,000,000
What happens
When no exchange rate is stored for a currency pair, Sure converts at 1:1 rather than declining to convert. A gain of ¥1,000,000 is reported as $1,000,000.
This is not a caller's mistake. ExchangeRate.rates_for returns 1 as its documented behaviour:
# Batch-fetches exchange rates for multiple source currencies.
# Returns a hash mapping each currency to its numeric rate, defaulting to 1 when unavailable.
def rates_for(currencies, to:, date: Date.current)Checked on a database with no JPY→USD rate:
rates_for(["JPY"], to: "USD") => {"JPY" => 1}So the || 1 at the two call sites in ReportsController is belt-and-braces over a helper that has already substituted 1.
Why it is easy to miss
For a major pair the answer looks plausible. EUR→USD at 1:1 instead of ~1.08 understates by 7%, which reads as a rounding quibble or a stale price.
For a currency whose unit is small it is not plausible at all:
| gain | reported | actual at a realistic rate | error |
|---|---|---|---|
| €10,000 | $10,000 | ~$10,800 | −7% |
| ¥1,000,000 | $1,000,000 | ~$6,667 | 150× |
| ₩10,000,000 | $10,000,000 | ~$7,400 | 1,350× |
A JPY or KRW holding with a missing rate does not produce a slightly wrong total — it produces a total dominated entirely by the unconverted figure, and nothing on the page says so.
Where it reaches
ExchangeRate.rates_for has five callers on main:
app/controllers/reports_controller.rb:562, :583 (unrealised and realised gains)
app/models/balance_sheet/account_totals.rb:97
app/models/concerns/accountable.rb:96
app/models/investment_statement.rb:614So this is not only the Reports page. The balance sheet converts the same way.
What is already handled, so the issue is not overstated
ReportsController does not fold an unmeasurable disposal into the total as zero — next if gain.nil? skips a trade whose cost basis cannot be determined (reports_controller.rb:605). That part is already right, and this issue is only about the currency conversion.
What we would propose
1. Stop the helper substituting a rate. rates_for should omit a pair it cannot convert rather than returning 1. Returning 1 makes "no rate" indistinguishable from "parity", and those are different facts.
2. Let each caller decide what to do with the absence, because the honest answer differs:
- Realised gains: exclude the disposal from the total and say how many were excluded. A realised figure is a sum of specific disposals, so leaving one out and naming it is honest; converting it at a guessed rate is not.
- Unrealised gains and the balance sheet: the same, though these are mark-to-market and a missing rate there usually means a missing price feed too.
3. Surface the exclusion. A total that leaves something out should say so on the page. A partial figure that does not announce itself reads as a whole one, which is the part that makes this a correctness problem rather than a display one.
What we have already done on our fork, if it is useful
jaysbeekay/sure has run the realised half of this. ReportsController's realised conversion returns nil rather than 1 and skips the disposal, and a separate Portfolio::RealizedGains engine excludes unmeasurable disposals and reports the count and reason to the user. Both have been in use for a few weeks.
The unrealised leg and the shared helper are untouched there too, so this issue describes work we have not finished either.
Happy to open a PR for the helper change and the realised-gains caller if that shape is agreeable. The helper change is the one that needs a decision first, since every caller inherits it and two of them are outside the investments area.
Reproducing
With no JPY→USD rate stored, a sell trade denominated in JPY shows its full yen figure as dollars on the Reports gains card. ExchangeRate.rates_for(["JPY"], to: "USD") returning {"JPY" => 1} is the whole mechanism.
Source: we-promise/sure