mirror of
https://github.com/maybe-finance/maybe.git
synced 2025-08-02 20:15:22 +02:00
Realistic demo data for performance testing (#2361)
* Realistic demo data for performance testing * Add note about performance testing * Fix bugbot issues * More realistic account values
This commit is contained in:
parent
0d62e60da1
commit
5a4c955522
16 changed files with 2166 additions and 474 deletions
448
app/models/demo/transaction_generator.rb
Normal file
448
app/models/demo/transaction_generator.rb
Normal file
|
@ -0,0 +1,448 @@
|
|||
class Demo::TransactionGenerator
|
||||
include Demo::DataHelper
|
||||
|
||||
def create_transaction!(attributes = {})
|
||||
# Separate entry attributes from transaction attributes
|
||||
entry_attributes = attributes.extract!(:account, :date, :amount, :currency, :name, :notes)
|
||||
transaction_attributes = attributes # category, merchant, etc.
|
||||
|
||||
# Set defaults for entry
|
||||
entry_defaults = {
|
||||
date: 30.days.ago.to_date,
|
||||
amount: 100,
|
||||
currency: "USD",
|
||||
name: "Demo Transaction"
|
||||
}
|
||||
|
||||
# Create entry with transaction as entryable
|
||||
entry = Entry.create!(
|
||||
entry_defaults.merge(entry_attributes).merge(
|
||||
entryable_type: "Transaction",
|
||||
entryable_attributes: transaction_attributes
|
||||
)
|
||||
)
|
||||
|
||||
entry.entryable # Returns the Transaction
|
||||
end
|
||||
|
||||
def create_trade!(attributes = {})
|
||||
# Separate entry attributes from trade attributes
|
||||
entry_attributes = attributes.extract!(:account, :date, :amount, :currency, :name, :notes)
|
||||
trade_attributes = attributes # security, qty, price, etc.
|
||||
|
||||
# Validate required trade attributes
|
||||
security = trade_attributes[:security] || Security.first
|
||||
unless security
|
||||
raise ArgumentError, "Security is required for trade creation. Load securities first."
|
||||
end
|
||||
|
||||
# Set defaults for entry
|
||||
entry_defaults = {
|
||||
date: 30.days.ago.to_date,
|
||||
currency: "USD",
|
||||
name: "Demo Trade"
|
||||
}
|
||||
|
||||
# Set defaults for trade
|
||||
trade_defaults = {
|
||||
qty: 10,
|
||||
price: 100,
|
||||
currency: "USD"
|
||||
}
|
||||
|
||||
# Merge defaults with provided attributes
|
||||
final_entry_attributes = entry_defaults.merge(entry_attributes)
|
||||
final_trade_attributes = trade_defaults.merge(trade_attributes)
|
||||
final_trade_attributes[:security] = security
|
||||
|
||||
# Calculate amount if not provided (qty * price)
|
||||
unless final_entry_attributes[:amount]
|
||||
final_entry_attributes[:amount] = final_trade_attributes[:qty] * final_trade_attributes[:price]
|
||||
end
|
||||
|
||||
# Create entry with trade as entryable
|
||||
entry = Entry.create!(
|
||||
final_entry_attributes.merge(
|
||||
entryable_type: "Trade",
|
||||
entryable_attributes: final_trade_attributes
|
||||
)
|
||||
)
|
||||
|
||||
entry.entryable # Returns the Trade
|
||||
end
|
||||
|
||||
def create_realistic_transactions!(family)
|
||||
categories = family.categories.limit(10)
|
||||
accounts_by_type = group_accounts_by_type(family)
|
||||
entries = []
|
||||
|
||||
# Create initial valuations for accounts before other transactions
|
||||
entries.concat(create_initial_valuations!(family))
|
||||
|
||||
accounts_by_type[:checking].each do |account|
|
||||
entries.concat(create_income_transactions!(account))
|
||||
entries.concat(create_expense_transactions!(account, categories))
|
||||
end
|
||||
|
||||
accounts_by_type[:credit_cards].each do |account|
|
||||
entries.concat(create_credit_card_transactions!(account, categories))
|
||||
end
|
||||
|
||||
accounts_by_type[:investments].each do |account|
|
||||
entries.concat(create_investment_trades!(account))
|
||||
end
|
||||
|
||||
# Update account balances to match transaction sums
|
||||
update_account_balances_from_transactions!(family)
|
||||
|
||||
entries
|
||||
end
|
||||
|
||||
def create_performance_transactions!(family)
|
||||
categories = family.categories.limit(5)
|
||||
accounts_by_type = group_accounts_by_type(family)
|
||||
entries = []
|
||||
|
||||
# Create initial valuations for accounts before other transactions
|
||||
entries.concat(create_initial_valuations!(family))
|
||||
|
||||
accounts_by_type[:checking].each do |account|
|
||||
entries.concat(create_bulk_transactions!(account, PERFORMANCE_TRANSACTION_COUNTS[:depository_sample], income: true))
|
||||
entries.concat(create_bulk_transactions!(account, PERFORMANCE_TRANSACTION_COUNTS[:depository_sample], income: false))
|
||||
end
|
||||
|
||||
accounts_by_type[:credit_cards].each do |account|
|
||||
entries.concat(create_bulk_transactions!(account, PERFORMANCE_TRANSACTION_COUNTS[:credit_card_sample], credit_card: true))
|
||||
end
|
||||
|
||||
accounts_by_type[:investments].each do |account|
|
||||
entries.concat(create_bulk_investment_trades!(account, PERFORMANCE_TRANSACTION_COUNTS[:investment_trades]))
|
||||
end
|
||||
|
||||
# Update account balances to match transaction sums
|
||||
update_account_balances_from_transactions!(family)
|
||||
|
||||
entries
|
||||
end
|
||||
|
||||
# Create initial valuations for accounts to establish realistic starting values
|
||||
# This is more appropriate than fake transactions
|
||||
def create_initial_valuations!(family)
|
||||
entries = []
|
||||
|
||||
family.accounts.each do |account|
|
||||
initial_value = case account.accountable_type
|
||||
when "Loan"
|
||||
case account.name
|
||||
when /Mortgage/i then 300000 # Initial mortgage debt
|
||||
when /Auto/i, /Car/i then 15000 # Initial car loan debt
|
||||
else 10000 # Other loan debt
|
||||
end
|
||||
when "CreditCard"
|
||||
5000 # Initial credit card debt
|
||||
when "Property"
|
||||
500000 # Initial property value
|
||||
when "Vehicle"
|
||||
25000 # Initial vehicle value
|
||||
when "OtherAsset"
|
||||
5000 # Initial other asset value
|
||||
when "OtherLiability"
|
||||
2000 # Initial other liability debt
|
||||
else
|
||||
next # Skip accounts that don't need initial valuations
|
||||
end
|
||||
|
||||
# Create valuation entry
|
||||
entry = Entry.create!(
|
||||
account: account,
|
||||
amount: initial_value,
|
||||
name: "Initial #{account.accountable_type.humanize.downcase} valuation",
|
||||
date: 2.years.ago.to_date,
|
||||
currency: account.currency,
|
||||
entryable_type: "Valuation",
|
||||
entryable_attributes: {}
|
||||
)
|
||||
entries << entry
|
||||
end
|
||||
|
||||
entries
|
||||
end
|
||||
|
||||
# Update account balances to match the sum of their transactions and valuations
|
||||
# This ensures realistic balances without artificial balancing transactions
|
||||
def update_account_balances_from_transactions!(family)
|
||||
family.accounts.each do |account|
|
||||
transaction_sum = account.entries
|
||||
.where(entryable_type: [ "Transaction", "Trade", "Valuation" ])
|
||||
.sum(:amount)
|
||||
|
||||
# Calculate realistic balance based on transaction sum and account type
|
||||
# For assets: balance should be positive, so we negate the transaction sum
|
||||
# For liabilities: balance should reflect debt owed
|
||||
new_balance = case account.classification
|
||||
when "asset"
|
||||
-transaction_sum # Assets: negative transaction sum = positive balance
|
||||
when "liability"
|
||||
transaction_sum # Liabilities: positive transaction sum = positive debt balance
|
||||
else
|
||||
-transaction_sum
|
||||
end
|
||||
|
||||
# Ensure minimum realistic balance
|
||||
new_balance = [ new_balance, minimum_realistic_balance(account) ].max
|
||||
|
||||
account.update!(balance: new_balance)
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def create_income_transactions!(account)
|
||||
entries = []
|
||||
|
||||
6.times do |i|
|
||||
transaction = create_transaction!(
|
||||
account: account,
|
||||
name: "Salary #{i + 1}",
|
||||
amount: -income_amount,
|
||||
date: random_date_within_days(90),
|
||||
currency: account.currency
|
||||
)
|
||||
entries << transaction.entry
|
||||
end
|
||||
|
||||
entries
|
||||
end
|
||||
|
||||
def create_expense_transactions!(account, categories)
|
||||
entries = []
|
||||
expense_types = [
|
||||
{ name: "Grocery Store", amount_range: [ 50, 200 ] },
|
||||
{ name: "Gas Station", amount_range: [ 30, 80 ] },
|
||||
{ name: "Restaurant", amount_range: [ 25, 150 ] },
|
||||
{ name: "Online Purchase", amount_range: [ 20, 300 ] }
|
||||
]
|
||||
|
||||
20.times do
|
||||
expense_type = expense_types.sample
|
||||
transaction = create_transaction!(
|
||||
account: account,
|
||||
name: expense_type[:name],
|
||||
amount: expense_amount(expense_type[:amount_range][0], expense_type[:amount_range][1]),
|
||||
date: random_date_within_days(90),
|
||||
currency: account.currency,
|
||||
category: categories.sample
|
||||
)
|
||||
entries << transaction.entry
|
||||
end
|
||||
|
||||
entries
|
||||
end
|
||||
|
||||
def create_credit_card_transactions!(account, categories)
|
||||
entries = []
|
||||
|
||||
credit_card_merchants = [
|
||||
{ name: "Amazon Purchase", amount_range: [ 25, 500 ] },
|
||||
{ name: "Target", amount_range: [ 30, 150 ] },
|
||||
{ name: "Coffee Shop", amount_range: [ 5, 15 ] },
|
||||
{ name: "Department Store", amount_range: [ 50, 300 ] },
|
||||
{ name: "Subscription Service", amount_range: [ 10, 50 ] }
|
||||
]
|
||||
|
||||
25.times do
|
||||
merchant_data = credit_card_merchants.sample
|
||||
transaction = create_transaction!(
|
||||
account: account,
|
||||
name: merchant_data[:name],
|
||||
amount: expense_amount(merchant_data[:amount_range][0], merchant_data[:amount_range][1]),
|
||||
date: random_date_within_days(90),
|
||||
currency: account.currency,
|
||||
category: categories.sample
|
||||
)
|
||||
entries << transaction.entry
|
||||
end
|
||||
|
||||
entries
|
||||
end
|
||||
|
||||
def create_investment_trades!(account)
|
||||
securities = Security.limit(3)
|
||||
return [] unless securities.any?
|
||||
|
||||
entries = []
|
||||
|
||||
trade_patterns = [
|
||||
{ type: "buy", qty_range: [ 1, 50 ] },
|
||||
{ type: "buy", qty_range: [ 10, 100 ] },
|
||||
{ type: "sell", qty_range: [ 1, 25 ] }
|
||||
]
|
||||
|
||||
15.times do
|
||||
security = securities.sample
|
||||
pattern = trade_patterns.sample
|
||||
|
||||
recent_price = security.prices.order(date: :desc).first&.price || 100.0
|
||||
|
||||
trade = create_trade!(
|
||||
account: account,
|
||||
security: security,
|
||||
qty: rand(pattern[:qty_range][0]..pattern[:qty_range][1]),
|
||||
price: recent_price * (0.95 + rand * 0.1),
|
||||
date: random_date_within_days(90),
|
||||
currency: account.currency
|
||||
)
|
||||
entries << trade.entry
|
||||
end
|
||||
|
||||
entries
|
||||
end
|
||||
|
||||
def create_bulk_investment_trades!(account, count)
|
||||
securities = Security.limit(5)
|
||||
return [] unless securities.any?
|
||||
|
||||
entries = []
|
||||
|
||||
count.times do |i|
|
||||
security = securities.sample
|
||||
recent_price = security.prices.order(date: :desc).first&.price || 100.0
|
||||
|
||||
trade = create_trade!(
|
||||
account: account,
|
||||
security: security,
|
||||
qty: rand(1..100),
|
||||
price: recent_price * (0.9 + rand * 0.2),
|
||||
date: random_date_within_days(365),
|
||||
currency: account.currency,
|
||||
name: "Bulk Trade #{i + 1}"
|
||||
)
|
||||
entries << trade.entry
|
||||
end
|
||||
|
||||
entries
|
||||
end
|
||||
|
||||
def create_bulk_transactions!(account, count, income: false, credit_card: false)
|
||||
entries = []
|
||||
|
||||
# Handle credit cards specially to ensure balanced purchases and payments
|
||||
if account.accountable_type == "CreditCard"
|
||||
# Create a mix of purchases (positive) and payments (negative)
|
||||
purchase_count = (count * 0.8).to_i # 80% purchases
|
||||
payment_count = count - purchase_count # 20% payments
|
||||
|
||||
total_purchases = 0
|
||||
|
||||
# Create purchases first
|
||||
purchase_count.times do |i|
|
||||
amount = expense_amount(10, 200) # Credit card purchases (positive)
|
||||
total_purchases += amount
|
||||
|
||||
transaction = create_transaction!(
|
||||
account: account,
|
||||
name: "Bulk CC Purchase #{i + 1}",
|
||||
amount: amount,
|
||||
date: random_date_within_days(365),
|
||||
currency: account.currency
|
||||
)
|
||||
entries << transaction.entry
|
||||
end
|
||||
|
||||
# Create reasonable payments (negative amounts)
|
||||
# Payments should be smaller than total debt available
|
||||
initial_debt = 5000 # From initial valuation
|
||||
available_debt = initial_debt + total_purchases
|
||||
|
||||
payment_count.times do |i|
|
||||
# Payment should be reasonable portion of available debt
|
||||
max_payment = [ available_debt * 0.1, 500 ].max # 10% of debt or min $500
|
||||
amount = -expense_amount(50, max_payment.to_i) # Payment (negative)
|
||||
|
||||
transaction = create_transaction!(
|
||||
account: account,
|
||||
name: "Credit card payment #{i + 1}",
|
||||
amount: amount,
|
||||
date: random_date_within_days(365),
|
||||
currency: account.currency
|
||||
)
|
||||
entries << transaction.entry
|
||||
end
|
||||
|
||||
else
|
||||
# Regular handling for non-credit card accounts
|
||||
count.times do |i|
|
||||
amount = if income
|
||||
-income_amount # Income (negative)
|
||||
elsif credit_card
|
||||
expense_amount(10, 200) # This path shouldn't be reached for actual credit cards
|
||||
else
|
||||
expense_amount(5, 500) # Regular expenses (positive)
|
||||
end
|
||||
|
||||
name_prefix = if income
|
||||
"Bulk Income"
|
||||
elsif credit_card
|
||||
"Bulk CC Purchase"
|
||||
else
|
||||
"Bulk Expense"
|
||||
end
|
||||
|
||||
transaction = create_transaction!(
|
||||
account: account,
|
||||
name: "#{name_prefix} #{i + 1}",
|
||||
amount: amount,
|
||||
date: random_date_within_days(365),
|
||||
currency: account.currency
|
||||
)
|
||||
entries << transaction.entry
|
||||
end
|
||||
end
|
||||
|
||||
entries
|
||||
end
|
||||
|
||||
def expense_amount(min_or_range = :small, max = nil)
|
||||
if min_or_range.is_a?(Symbol)
|
||||
case min_or_range
|
||||
when :small then random_amount(10, 200)
|
||||
when :medium then random_amount(50, 500)
|
||||
when :large then random_amount(200, 1000)
|
||||
when :credit_card then random_amount(20, 300)
|
||||
else random_amount(10, 500)
|
||||
end
|
||||
else
|
||||
max_amount = max || (min_or_range + 100)
|
||||
random_amount(min_or_range, max_amount)
|
||||
end
|
||||
end
|
||||
|
||||
def income_amount(type = :salary)
|
||||
case type
|
||||
when :salary then random_amount(3000, 7000)
|
||||
when :dividend then random_amount(100, 500)
|
||||
when :interest then random_amount(50, 200)
|
||||
else random_amount(1000, 5000)
|
||||
end
|
||||
end
|
||||
|
||||
# Determine minimum realistic balance for account type
|
||||
def minimum_realistic_balance(account)
|
||||
case account.accountable_type
|
||||
when "Depository"
|
||||
account.subtype == "savings" ? 1000 : 500
|
||||
when "Investment"
|
||||
5000
|
||||
when "Property"
|
||||
100000
|
||||
when "Vehicle"
|
||||
5000
|
||||
when "OtherAsset"
|
||||
100
|
||||
when "CreditCard", "Loan", "OtherLiability"
|
||||
100 # Minimum debt
|
||||
else
|
||||
100
|
||||
end
|
||||
end
|
||||
end
|
Loading…
Add table
Add a link
Reference in a new issue