mirror of
https://github.com/maybe-finance/maybe.git
synced 2025-07-24 15:49:39 +02:00
Add the ability to "rollup" values in a time series (#554)
* Clean up time series models * Add value group rollup class for summarizing hierarchical data * Integrate new classes * Update UI to use new patterns * Update D3 charts to expect new data format * Clean up account model * More cleanup * Money improvements * Use new money fields * Remove invalid fixture data to avoid orphaned accountables * Update time series to work better with collections * Fix tests and UI bugs
This commit is contained in:
parent
0a8518506c
commit
f904d9d062
34 changed files with 687 additions and 391 deletions
2
test/fixtures/account/investments.yml
vendored
2
test/fixtures/account/investments.yml
vendored
|
@ -1,2 +0,0 @@
|
|||
one:
|
||||
id: "123e4567-e89b-12d3-a456-426614174004"
|
2
test/fixtures/account/loans.yml
vendored
2
test/fixtures/account/loans.yml
vendored
|
@ -1,2 +0,0 @@
|
|||
one:
|
||||
id: "123e4567-e89b-12d3-a456-426614174005"
|
2
test/fixtures/account/other_liabilities.yml
vendored
2
test/fixtures/account/other_liabilities.yml
vendored
|
@ -1,2 +0,0 @@
|
|||
one:
|
||||
id: "123e4567-e89b-12d3-a456-426614174006"
|
2
test/fixtures/account/properties.yml
vendored
2
test/fixtures/account/properties.yml
vendored
|
@ -1,2 +0,0 @@
|
|||
one:
|
||||
id: "123e4567-e89b-12d3-a456-426614174007"
|
2
test/fixtures/account/vehicles.yml
vendored
2
test/fixtures/account/vehicles.yml
vendored
|
@ -1,2 +0,0 @@
|
|||
one:
|
||||
id: "123e4567-e89b-12d3-a456-426614174008"
|
|
@ -65,26 +65,18 @@ class FamilyTest < ActiveSupport::TestCase
|
|||
liability_series = @family.snapshot[:liability_series]
|
||||
net_worth_series = @family.snapshot[:net_worth_series]
|
||||
|
||||
assert_equal expected_snapshots.count, asset_series.data.count
|
||||
assert_equal expected_snapshots.count, liability_series.data.count
|
||||
assert_equal expected_snapshots.count, net_worth_series.data.count
|
||||
assert_equal expected_snapshots.count, asset_series.values.count
|
||||
assert_equal expected_snapshots.count, liability_series.values.count
|
||||
assert_equal expected_snapshots.count, net_worth_series.values.count
|
||||
|
||||
expected_snapshots.each_with_index do |row, index|
|
||||
expected = {
|
||||
date: row["date"],
|
||||
assets: row["assets"].to_d,
|
||||
liabilities: row["liabilities"].to_d,
|
||||
net_worth: row["net_worth"].to_d
|
||||
}
|
||||
expected_assets = TimeSeries::Value.new(date: row["date"], value: Money.new(row["assets"].to_d))
|
||||
expected_liabilities = TimeSeries::Value.new(date: row["date"], value: Money.new(row["liabilities"].to_d))
|
||||
expected_net_worth = TimeSeries::Value.new(date: row["date"], value: Money.new(row["net_worth"].to_d))
|
||||
|
||||
actual = {
|
||||
date: asset_series.data[index][:date],
|
||||
assets: asset_series.data[index][:value].amount,
|
||||
liabilities: liability_series.data[index][:value].amount,
|
||||
net_worth: net_worth_series.data[index][:value].amount
|
||||
}
|
||||
|
||||
assert_equal expected, actual
|
||||
assert_equal expected_assets, asset_series.values[index]
|
||||
assert_equal expected_liabilities, liability_series.values[index]
|
||||
assert_equal expected_net_worth, net_worth_series.values[index]
|
||||
end
|
||||
end
|
||||
|
||||
|
@ -103,80 +95,4 @@ class FamilyTest < ActiveSupport::TestCase
|
|||
assert_equal liabilities_before - disabled_cc.balance, @family.liabilities
|
||||
assert_equal net_worth_before - disabled_checking.balance + disabled_cc.balance, @family.net_worth
|
||||
end
|
||||
|
||||
test "calculates balances by type" do
|
||||
verify_balances_by_type(
|
||||
period: Period.all,
|
||||
expected_asset_total: BigDecimal("25550"),
|
||||
expected_liability_total: BigDecimal("1000"),
|
||||
expected_asset_groups: {
|
||||
"Account::OtherAsset" => { end_balance: BigDecimal("550"), start_balance: BigDecimal("400"), allocation: 2.15 },
|
||||
"Account::Depository" => { end_balance: BigDecimal("25000"), start_balance: BigDecimal("25250"), allocation: 97.85 }
|
||||
},
|
||||
expected_liability_groups: {
|
||||
"Account::Credit" => { end_balance: BigDecimal("1000"), start_balance: BigDecimal("1040"), allocation: 100 }
|
||||
}
|
||||
)
|
||||
end
|
||||
|
||||
test "calculates balances by type with a date range filter" do
|
||||
verify_balances_by_type(
|
||||
period: Period.new(name: "custom", date_range: 7.days.ago.to_date..2.days.ago.to_date),
|
||||
expected_asset_total: BigDecimal("26050"),
|
||||
expected_liability_total: BigDecimal("1000"),
|
||||
expected_asset_groups: {
|
||||
"Account::OtherAsset" => { end_balance: BigDecimal("550"), start_balance: BigDecimal("700"), allocation: 2.11 },
|
||||
"Account::Depository" => { end_balance: BigDecimal("25500"), start_balance: BigDecimal("24510"), allocation: 97.89 }
|
||||
},
|
||||
expected_liability_groups: {
|
||||
"Account::Credit" => { end_balance: BigDecimal("1000"), start_balance: BigDecimal("990"), allocation: 100 }
|
||||
}
|
||||
)
|
||||
end
|
||||
|
||||
test "calculates balances by type with disabled account" do
|
||||
disabled_checking = accounts(:checking).update!(is_active: false)
|
||||
|
||||
verify_balances_by_type(
|
||||
period: Period.all,
|
||||
expected_asset_total: BigDecimal("20550"),
|
||||
expected_liability_total: BigDecimal("1000"),
|
||||
expected_asset_groups: {
|
||||
"Account::OtherAsset" => { end_balance: BigDecimal("550"), start_balance: BigDecimal("400"), allocation: 2.68 },
|
||||
"Account::Depository" => { end_balance: BigDecimal("20000"), start_balance: BigDecimal("21250"), allocation: 97.32 }
|
||||
},
|
||||
expected_liability_groups: {
|
||||
"Account::Credit" => { end_balance: BigDecimal("1000"), start_balance: BigDecimal("1040"), allocation: 100 }
|
||||
}
|
||||
)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def verify_balances_by_type(period:, expected_asset_total:, expected_liability_total:, expected_asset_groups:, expected_liability_groups:)
|
||||
result = @family.accounts.by_group(period)
|
||||
|
||||
asset_total = result[:asset][:total]
|
||||
liability_total = result[:liability][:total]
|
||||
|
||||
assert_equal expected_asset_total, asset_total
|
||||
assert_equal expected_liability_total, liability_total
|
||||
|
||||
asset_groups = result[:asset][:groups]
|
||||
liability_groups = result[:liability][:groups]
|
||||
|
||||
assert_equal expected_asset_groups.keys, asset_groups.keys
|
||||
expected_asset_groups.each do |type, expected_values|
|
||||
assert_equal expected_values[:end_balance], asset_groups[type][:end_balance]
|
||||
assert_equal expected_values[:start_balance], asset_groups[type][:start_balance]
|
||||
assert_equal expected_values[:allocation], asset_groups[type][:allocation]
|
||||
end
|
||||
|
||||
assert_equal expected_liability_groups.keys, liability_groups.keys
|
||||
expected_liability_groups.each do |type, expected_values|
|
||||
assert_equal expected_values[:end_balance], liability_groups[type][:end_balance]
|
||||
assert_equal expected_values[:start_balance], liability_groups[type][:start_balance]
|
||||
assert_equal expected_values[:allocation], liability_groups[type][:allocation]
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
45
test/models/time_series/trend_test.rb
Normal file
45
test/models/time_series/trend_test.rb
Normal file
|
@ -0,0 +1,45 @@
|
|||
require "test_helper"
|
||||
|
||||
class TimeSeries::TrendTest < ActiveSupport::TestCase
|
||||
test "handles money trend" do
|
||||
trend = TimeSeries::Trend.new(current: Money.new(100), previous: Money.new(50))
|
||||
assert_equal "up", trend.direction
|
||||
assert_equal Money.new(50), trend.value
|
||||
assert_equal 100.0, trend.percent
|
||||
end
|
||||
test "up" do
|
||||
trend = TimeSeries::Trend.new(current: 100, previous: 50)
|
||||
assert_equal "up", trend.direction
|
||||
end
|
||||
|
||||
test "down" do
|
||||
trend = TimeSeries::Trend.new(current: 50, previous: 100)
|
||||
assert_equal "down", trend.direction
|
||||
end
|
||||
|
||||
test "flat" do
|
||||
trend1 = TimeSeries::Trend.new(current: 100, previous: 100)
|
||||
trend3 = TimeSeries::Trend.new(current: 100, previous: nil)
|
||||
trend2 = TimeSeries::Trend.new(current: nil, previous: nil)
|
||||
assert_equal "flat", trend1.direction
|
||||
assert_equal "flat", trend2.direction
|
||||
assert_equal "flat", trend3.direction
|
||||
end
|
||||
|
||||
test "infinitely up" do
|
||||
trend = TimeSeries::Trend.new(current: 100, previous: 0)
|
||||
assert_equal "up", trend.direction
|
||||
end
|
||||
|
||||
test "infinitely down" do
|
||||
trend1 = TimeSeries::Trend.new(current: nil, previous: 100)
|
||||
trend2 = TimeSeries::Trend.new(current: 0, previous: 100)
|
||||
assert_equal "down", trend1.direction
|
||||
assert_equal "down", trend2.direction
|
||||
end
|
||||
|
||||
test "empty" do
|
||||
trend =TimeSeries::Trend.new
|
||||
assert_equal "flat", trend.direction
|
||||
end
|
||||
end
|
75
test/models/time_series_test.rb
Normal file
75
test/models/time_series_test.rb
Normal file
|
@ -0,0 +1,75 @@
|
|||
require "test_helper"
|
||||
|
||||
class TimeSeriesTest < ActiveSupport::TestCase
|
||||
test "it can accept array of money values" do
|
||||
series = TimeSeries.new([ { date: 1.day.ago.to_date, value: Money.new(100) }, { date: Date.current, value: Money.new(200) } ])
|
||||
|
||||
assert_equal Money.new(100), series.first.value
|
||||
assert_equal Money.new(200), series.last.value
|
||||
assert_equal :normal, series.type
|
||||
assert_equal "up", series.trend.direction
|
||||
assert_equal Money.new(100), series.trend.value
|
||||
assert_equal 100.0, series.trend.percent
|
||||
end
|
||||
|
||||
test "it can accept array of numeric values" do
|
||||
series = TimeSeries.new([ { date: 1.day.ago.to_date, value: 100 }, { date: Date.current, value: 200 } ])
|
||||
|
||||
assert_equal 100, series.first.value
|
||||
assert_equal 200, series.last.value
|
||||
assert_equal 100, series.on(1.day.ago.to_date).value
|
||||
assert_equal :normal, series.type
|
||||
assert_equal "up", series.trend.direction
|
||||
assert_equal 100, series.trend.value
|
||||
assert_equal 100.0, series.trend.percent
|
||||
end
|
||||
|
||||
test "when nil or empty array passed, it returns empty series" do
|
||||
series = TimeSeries.new(nil)
|
||||
assert_equal [], series.values
|
||||
|
||||
series = TimeSeries.new([])
|
||||
|
||||
assert_nil series.first
|
||||
assert_nil series.last
|
||||
assert_equal({ values: [], trend: { type: "normal", direction: "flat", value: 0, percent: 0.0 }, type: "normal" }.to_json, series.to_json)
|
||||
end
|
||||
|
||||
test "money series can be serialized to json" do
|
||||
expected_values = {
|
||||
values: [
|
||||
{
|
||||
date: "2024-03-17",
|
||||
value: { amount: "100.0", currency: "USD" },
|
||||
trend: { type: "normal", direction: "flat", value: { amount: "0.0", currency: "USD" }, percent: 0.0 }
|
||||
},
|
||||
{
|
||||
date: "2024-03-18",
|
||||
value: { amount: "200.0", currency: "USD" },
|
||||
trend: { type: "normal", direction: "up", value: { amount: "100.0", currency: "USD" }, percent: 100.0 }
|
||||
}
|
||||
],
|
||||
trend: { type: "normal", direction: "up", value: { amount: "100.0", currency: "USD" }, percent: 100.0 },
|
||||
type: "normal"
|
||||
}.to_json
|
||||
|
||||
series = TimeSeries.new([ { date: 1.day.ago.to_date, value: Money.new(100) }, { date: Date.current, value: Money.new(200) } ])
|
||||
|
||||
assert_equal expected_values, series.to_json
|
||||
end
|
||||
|
||||
test "numeric series can be serialized to json" do
|
||||
expected_values = {
|
||||
values: [
|
||||
{ date: 1.day.ago.to_date, value: 100, trend: { type: "normal", direction: "flat", value: 0, percent: 0.0 } },
|
||||
{ date: Date.current, value: 200, trend: { type: "normal", direction: "up", value: 100, percent: 100.0 } }
|
||||
],
|
||||
trend: { type: "normal", direction: "up", value: 100, percent: 100.0 },
|
||||
type: "normal"
|
||||
}.to_json
|
||||
|
||||
series = TimeSeries.new([ { date: 1.day.ago.to_date, value: 100 }, { date: Date.current, value: 200 } ])
|
||||
|
||||
assert_equal expected_values, series.to_json
|
||||
end
|
||||
end
|
|
@ -1,37 +0,0 @@
|
|||
require "test_helper"
|
||||
|
||||
class TrendTest < ActiveSupport::TestCase
|
||||
test "up" do
|
||||
trend = Trend.new(current: 100, previous: 50)
|
||||
assert_equal "up", trend.direction
|
||||
end
|
||||
|
||||
test "down" do
|
||||
trend = Trend.new(current: 50, previous: 100)
|
||||
assert_equal "down", trend.direction
|
||||
end
|
||||
|
||||
test "flat" do
|
||||
trend = Trend.new(current: 100, previous: 100)
|
||||
assert_equal "flat", trend.direction
|
||||
end
|
||||
|
||||
test "infinitely up" do
|
||||
trend1 = Trend.new(current: 100, previous: nil)
|
||||
trend2 = Trend.new(current: 100, previous: 0)
|
||||
assert_equal "up", trend1.direction
|
||||
assert_equal "up", trend2.direction
|
||||
end
|
||||
|
||||
test "infinitely down" do
|
||||
trend1 = Trend.new(current: nil, previous: 100)
|
||||
trend2 = Trend.new(current: 0, previous: 100)
|
||||
assert_equal "down", trend1.direction
|
||||
assert_equal "down", trend2.direction
|
||||
end
|
||||
|
||||
test "empty" do
|
||||
trend = Trend.new
|
||||
assert_equal "flat", trend.direction
|
||||
end
|
||||
end
|
145
test/models/value_group_test.rb
Normal file
145
test/models/value_group_test.rb
Normal file
|
@ -0,0 +1,145 @@
|
|||
require "test_helper"
|
||||
|
||||
class ValueGroupTest < ActiveSupport::TestCase
|
||||
setup do
|
||||
checking = accounts(:checking)
|
||||
savings = accounts(:savings_with_valuation_overrides)
|
||||
collectable = accounts(:collectable)
|
||||
|
||||
# Level 1
|
||||
@assets = ValueGroup.new("Assets")
|
||||
|
||||
# Level 2
|
||||
@depositories = @assets.add_child_node("Depositories")
|
||||
@other_assets = @assets.add_child_node("Other Assets")
|
||||
|
||||
# Level 3 (leaf/value nodes)
|
||||
@checking_node = @depositories.add_value_node(checking)
|
||||
@savings_node = @depositories.add_value_node(savings)
|
||||
@collectable_node = @other_assets.add_value_node(collectable)
|
||||
end
|
||||
|
||||
test "empty group works" do
|
||||
group = ValueGroup.new
|
||||
|
||||
assert_equal "Root", group.name
|
||||
assert_equal [], group.children
|
||||
assert_equal 0, group.sum
|
||||
assert_equal 0, group.avg
|
||||
assert_equal 100, group.percent_of_total
|
||||
assert_nil group.parent
|
||||
end
|
||||
|
||||
test "group without value nodes has no value" do
|
||||
assets = ValueGroup.new("Assets")
|
||||
depositories = assets.add_child_node("Depositories")
|
||||
|
||||
assert_equal 0, assets.sum
|
||||
assert_equal 0, depositories.sum
|
||||
end
|
||||
|
||||
test "sum equals value at leaf level" do
|
||||
assert_equal @checking_node.value, @checking_node.sum
|
||||
assert_equal @savings_node.value, @savings_node.sum
|
||||
assert_equal @collectable_node.value, @collectable_node.sum
|
||||
end
|
||||
|
||||
test "value is nil at rollup levels" do
|
||||
assert_not_equal @depositories.value, @depositories.sum
|
||||
assert_nil @depositories.value
|
||||
assert_nil @other_assets.value
|
||||
end
|
||||
|
||||
test "generates list of value nodes regardless of level in hierarchy" do
|
||||
assert_equal [ @checking_node, @savings_node, @collectable_node ], @assets.value_nodes
|
||||
assert_equal [ @checking_node, @savings_node ], @depositories.value_nodes
|
||||
assert_equal [ @collectable_node ], @other_assets.value_nodes
|
||||
end
|
||||
|
||||
test "group with value nodes aggregates totals correctly" do
|
||||
assert_equal 5000, @checking_node.sum
|
||||
assert_equal 20000, @savings_node.sum
|
||||
assert_equal 550, @collectable_node.sum
|
||||
|
||||
assert_equal 25000, @depositories.sum
|
||||
assert_equal 550, @other_assets.sum
|
||||
|
||||
assert_equal 25550, @assets.sum
|
||||
end
|
||||
|
||||
test "group averages leaf nodes" do
|
||||
assert_equal 5000, @checking_node.avg
|
||||
assert_equal 20000, @savings_node.avg
|
||||
assert_equal 550, @collectable_node.avg
|
||||
|
||||
assert_in_delta 12500, @depositories.avg, 0.01
|
||||
assert_in_delta 550, @other_assets.avg, 0.01
|
||||
assert_in_delta 8516.67, @assets.avg, 0.01
|
||||
end
|
||||
|
||||
# Percentage of parent group (i.e. collectable is 100% of "Other Assets" group)
|
||||
test "group calculates percent of parent total" do
|
||||
assert_equal 100, @assets.percent_of_total
|
||||
assert_in_delta 97.85, @depositories.percent_of_total, 0.1
|
||||
assert_in_delta 2.15, @other_assets.percent_of_total, 0.1
|
||||
assert_in_delta 80.0, @savings_node.percent_of_total, 0.1
|
||||
assert_in_delta 20.0, @checking_node.percent_of_total, 0.1
|
||||
assert_equal 100, @collectable_node.percent_of_total
|
||||
end
|
||||
|
||||
test "handles unbalanced tree" do
|
||||
vehicles = @assets.add_child_node("Vehicles")
|
||||
|
||||
# Since we didn't add any value nodes to vehicles, shouldn't affect rollups
|
||||
assert_equal 25550, @assets.sum
|
||||
end
|
||||
|
||||
|
||||
test "can attach and aggregate time series" do
|
||||
checking_series = TimeSeries.new([ { date: 1.day.ago.to_date, value: 4000 }, { date: Date.current, value: 5000 } ])
|
||||
savings_series = TimeSeries.new([ { date: 1.day.ago.to_date, value: 19000 }, { date: Date.current, value: 20000 } ])
|
||||
|
||||
@checking_node.attach_series(checking_series)
|
||||
@savings_node.attach_series(savings_series)
|
||||
|
||||
assert_not_nil @checking_node.series
|
||||
assert_not_nil @savings_node.series
|
||||
|
||||
assert_equal @checking_node.sum, @checking_node.series.last.value
|
||||
assert_equal @savings_node.sum, @savings_node.series.last.value
|
||||
|
||||
aggregated_depository_series = TimeSeries.new([ { date: 1.day.ago.to_date, value: 23000 }, { date: Date.current, value: 25000 } ])
|
||||
aggregated_assets_series = TimeSeries.new([ { date: 1.day.ago.to_date, value: 23000 }, { date: Date.current, value: 25000 } ])
|
||||
|
||||
assert_equal aggregated_depository_series.values, @depositories.series.values
|
||||
assert_equal aggregated_assets_series.values, @assets.series.values
|
||||
end
|
||||
|
||||
test "attached series must be a TimeSeries" do
|
||||
assert_raises(RuntimeError) do
|
||||
@checking_node.attach_series([])
|
||||
end
|
||||
end
|
||||
|
||||
test "cannot add time series to non-leaf node" do
|
||||
assert_raises(RuntimeError) do
|
||||
@assets.attach_series(TimeSeries.new([]))
|
||||
end
|
||||
end
|
||||
|
||||
test "can only add value node at leaf level of tree" do
|
||||
root = ValueGroup.new("Root Level")
|
||||
grandparent = root.add_child_node("Grandparent")
|
||||
parent = grandparent.add_child_node("Parent")
|
||||
|
||||
value_node = parent.add_value_node(OpenStruct.new({ name: "Value Node", value: 100 }))
|
||||
|
||||
assert_raises(RuntimeError) do
|
||||
value_node.add_value_node(OpenStruct.new({ name: "Value Node", value: 100 }))
|
||||
end
|
||||
|
||||
assert_raises(RuntimeError) do
|
||||
grandparent.add_value_node(OpenStruct.new({ name: "Value Node", value: 100 }))
|
||||
end
|
||||
end
|
||||
end
|
Loading…
Add table
Add a link
Reference in a new issue