mirror of
https://github.com/we-promise/sure.git
synced 2026-09-09 08:34:26 +00:00
* 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>
162 lines
5.4 KiB
Ruby
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
|