1
0
Fork 0
mirror of https://github.com/maybe-finance/maybe.git synced 2025-07-19 05:09:38 +02:00
Maybe/app/models/demo/transaction_generator.rb
Zach Gollwitzer 5a4c955522
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
2025-06-11 18:48:39 -04:00

448 lines
14 KiB
Ruby

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