Files
sure/test/models/trade_test.rb
T
buzzromainandClaude Opus 5 4fbc0a4eb1 fix(reports): a transfer out is not a sale (#3192)
* fix(reports): a transfer out is not a sale

A negative quantity is all it took to be counted as a sale. So moving an
asset to another account you own — a transfer, a sweep, an exchange —
was listed among the period's sales, and its cost basis was compared
against that day's price to book a gain nobody made. Nothing was sold and
nothing was realised.

The labels for this already exist and are already trusted elsewhere:
Transaction::INTERNAL_MOVEMENT_LABELS keeps the same four out of the
income statement, for the same reason. The investment report just never
consulted them.

Two places learn to ask. Trade#realized_gain_loss returns nil for an
internal movement, so no caller can book the gain; and the report's query
leaves those trades out, so the movement is no longer counted or listed
as a sale it never was.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013nN1GZi7Dv3d7yZUozw3Yo

* fix(reports): keep a security exchange out of the internal-movement list

Review on this PR. `Trade::INTERNAL_MOVEMENT_LABELS` aliased Transaction's,
which holds "Exchange". On cash that means a currency exchange and really is
internal. On a security the label covers "currency **or** security
exchanges" — the repo's own guide says so — and a security-for-security
exchange can dispose of an appreciated asset.

So a labelled exchange dropped out of the sales report *and*
`realized_gain_loss` returned nil for it. The gain did not move; it stopped
existing.

The two errors are not symmetrical, which is what decided this. Listing a
movement that was not a sale is visible and correctable. Erasing a realized
gain is neither — nothing on the page says a figure is missing. So the trade
list keeps only the labels that unambiguously preserve ownership, and leaves
the ambiguous one where the user can see it.

Also from review: the test asked for `period: "last_30_days"`, but the
controller reads `period_type`, so the request silently fell back to the
current month and the trades dated three days earlier dropped out of range
on the 1st to the 3rd. Confirmed with `travel_to Date.new(2026, 9, 2)`:
fails on the old parameter, passes on the new one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016GTNba5qE5NwzaHzbp27ye

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 07:16:40 +02:00

162 lines
5.4 KiB
Ruby

require "test_helper"
class TradeTest < ActiveSupport::TestCase
test "build_name generates buy trade name" do
name = Trade.build_name("buy", 10, "AAPL")
assert_equal "Buy 10.0 shares of AAPL", name
end
test "build_name generates sell trade name" do
name = Trade.build_name("sell", 5, "MSFT")
assert_equal "Sell 5.0 shares of MSFT", name
end
test "build_name handles absolute value for negative quantities" do
name = Trade.build_name("sell", -5, "GOOGL")
assert_equal "Sell 5.0 shares of GOOGL", name
end
test "build_name handles decimal quantities" do
name = Trade.build_name("buy", 0.25, "BTC")
assert_equal "Buy 0.25 shares of BTC", name
end
test "price scale is preserved at 10 decimal places" do
security = Security.create!(ticker: "TEST", exchange_operating_mic: "XNAS")
# up to 10 decimal places — should persist exactly
precise_price = BigDecimal("12.3456789012")
trade = Trade.create!(
security: security,
price: precise_price,
qty: 10000,
currency: "USD",
investment_activity_label: "Buy"
)
trade.reload
assert_equal precise_price, trade.price
end
test "fee defaults to 0" do
security = Security.create!(ticker: "FEETEST", exchange_operating_mic: "XNAS")
trade = Trade.create!(
security: security,
price: 100,
qty: 10,
currency: "USD",
investment_activity_label: "Buy"
)
assert_equal 0, trade.fee
end
test "exchange_rate setter stores normalized numeric value in extra" do
trade = Trade.new
trade.exchange_rate = "0.91"
assert_equal 0.91, trade.exchange_rate
assert_equal 0.91, trade.extra["exchange_rate"]
end
test "exchange_rate validation rejects invalid values" do
trade = Trade.new
trade.exchange_rate = "invalid"
assert_not trade.valid?
assert_includes trade.errors[:exchange_rate], "must be a number"
end
test "exchange_rate validation rejects non-finite values" do
trade = Trade.new
trade.exchange_rate = "NaN"
assert_not trade.valid?
assert_includes trade.errors[:exchange_rate], "must be a number"
end
test "price is rounded to 10 decimal places" do
security = Security.create!(ticker: "TEST", exchange_operating_mic: "XNAS")
# over 10 decimal places — will be rounded
price_with_too_many_decimals = BigDecimal("1.123456789012345")
trade = Trade.create!(
security: security,
price: price_with_too_many_decimals,
qty: 1,
currency: "USD",
investment_activity_label: "Buy"
)
trade.reload
assert_equal BigDecimal("1.1234567890"), trade.price
end
test "a transfer out realises nothing, however it is priced" do
account, security = position_with_known_cost_basis
moved = build_negative_trade(account, security, label: "Transfer")
sold = build_negative_trade(account, security, label: "Sell")
# Same sign, same shape, same price — only the label separates a sale from
# coins walking to another wallet you own.
assert_nil moved.realized_gain_loss
assert_not_nil sold.realized_gain_loss
end
test "every internal movement label realises nothing" do
account, security = position_with_known_cost_basis
Trade::INTERNAL_MOVEMENT_LABELS.each do |label|
trade = build_negative_trade(account, security, label: label)
assert_nil trade.realized_gain_loss, "#{label} should not realise a gain"
end
end
private
# A position whose cost basis is known, which is what makes a fabricated
# gain possible: without one, realized_gain_loss returns nil for any reason.
def position_with_known_cost_basis
family = families(:empty)
account = family.accounts.create!(name: "Wallet", balance: 100, currency: "USD",
accountable: Investment.new)
security = Security.find_or_create_by!(ticker: "MOVE") { |s| s.name = "Movable" }
Holding.create!(account: account, security: security, date: 10.days.ago.to_date,
qty: 100, price: 2, amount: 200, currency: "USD", cost_basis: 1)
[ account, security ]
end
def build_negative_trade(account, security, label:)
account.entries.create!(
date: 3.days.ago.to_date, name: "out #{label}", amount: 0, currency: "USD",
entryable: Trade.new(security: security, qty: -40, price: 3, currency: "USD",
investment_activity_label: label)
).entryable
end
# "Exchange" means a currency exchange on cash and is internal there. On a
# security the label covers currency *or security* exchanges, and a
# security-for-security exchange can dispose of an appreciated asset — so
# borrowing Transaction's list erased a realized gain with nothing to show
# for it.
test "an exchange is not treated as an internal movement on a trade" do
assert_not Trade.new(investment_activity_label: "Exchange").internal_movement?
end
test "the labels that unambiguously preserve ownership still are" do
%w[Transfer Sweep\ In Sweep\ Out].each do |label|
assert Trade.new(investment_activity_label: label).internal_movement?, label
end
end
# The two lists are deliberately different; this fails if one is ever
# aliased back onto the other.
test "a trade does not borrow the cash list" do
assert_includes Transaction::INTERNAL_MOVEMENT_LABELS, "Exchange"
assert_not_includes Trade::INTERNAL_MOVEMENT_LABELS, "Exchange"
end
end