xxxxxxxxxx
479
//
// Dividend
//
// Originally by Philip, February 21, 2021
//
//
// Modified in December of 2022 for FairShare (https://fairshare.social)
// Modified in January 2024 to simplify transaction fee
// Modified in March 2024 to add normal distribution for productivity
//
// Simulate how money moves around between people in an economy,
// with the goal of seeing how different economic policies affect
// wealth distribution, in particular a daily income funded by
// a transaction tax.
//
// The size (area) of a person is proportional to their wealth.
// Collisions are an opportunity for trade. Trades are an exchange
// where the amount exchange is a fraction of the less wealthy
// person's balance and the buyer/seller is random.
//
// The Gini Index is constantly computed and displayed.
//
// To-Do:
// <DONE> Add measurement of how long it takes to reach a given Gini index.
// <DONE> Add gaussian productivity per person, add to trade
//
const defaultGroupSize = 500 // How many people in the simulation?
const balance_initial = 3400.0 // How much money does everyone start with?
// $3,400 is the median cash balance for USA
const transactions_per_day = 2 // How many transactions per day does an average person make?
const fraction_to_risk = 0.05 // What % of your wealth will you risk in a transaction?
// 5% is the average USA credit card payment
// compared to median cash balance.
const poorerLimit = false; // If true, simulates real market with trade set by poorer balance,
// and if false, trade amount is set by buyer balance.
const useProductivity = true; // If true, uses productivity to adjust trade direction
const stdevProductivity = 0.5; // If using productivity, How differently skilled are people from each other?
const giniTarget = 0.9
let giniTargetDays = 0
let groupSize
let transactionFee
const defaultTransactionFee = 0.00
let adjustRadius = 0.05 // How big are the people relative to their wealth?
let minRadius = 2
let days = 0 // How many days have gone by?
let frame = 0
let days_last_frame = 0
let transactions = 0
let collectedFees = 0
let averageDailyIncome = 0
let v_min = 10
let v_max = 50
let r_min = 5
let people = []
let repeat = false
let collisions = true
let frozen = false
let moneySupply = balance_initial * groupSize
let averageBalance = moneySupply / groupSize
let numRich = 0
let numPoor = 0
let giniIndex = 0
let blue = ""
let red = ""
let green = ""
let black = ""
// A function to map the gini coefficient to an angle in radians between -PI/2 and PI/2
function giniToAngle(gini) {
return map(gini, 0, 100, -PI/2, PI/2)
}
function maybeTrade(i, j) {
//
// Two people meet, and maybe they make a transaction
//
let richer = j
let poorer = i
if (people[i].balance >= people[j].balance) {
richer = i
poorer = j
}
// Randomly choose who is the buyer and who is the seller
let buy = (random() > 0.5);
/*
if (useProductivity) {
buy = ((random() + people[richer].productivity - people[poorer].productivity) > 0.5);
} else {
buy = (random() > 0.5);
} */
let buyer = (buy) ? richer : poorer
let seller = (buy) ? poorer : richer
let amount = 0;
if (poorerLimit) {
amount = people[poorer].balance * fraction_to_risk
} else {
amount = people[buyer].balance * fraction_to_risk
}
if (useProductivity) {
// Adjust seller's amount received by their productivity
amount *= (1 + people[seller].productivity);
if (amount > people[buyer].balance) amount = people[buyer].balance;
}
let tax = amount * transactionFee
if (people[buyer].balance >= amount) {
people[buyer].balance -= amount
people[seller].balance += amount - tax
transactions++
collectedFees += tax
}
}
function distributeFees(fees) {
for (let i = 0; i < groupSize; i++) {
people[i].balance += fees / groupSize
}
}
function setup() {
createCanvas(800, 600)
noStroke()
blue = color(0, 0, 255)
red = color(255, 0, 0)
green = color(0, 255, 0)
black = color(0, 0, 0)
createGroup(defaultGroupSize)
transactionFee = defaultTransactionFee
createSliders(transactionFee, defaultGroupSize)
updateLabels(true)
angleMode(RADIANS) // Set the angle mode to radians
}
function createGroup(size) {
groupSize = size
people = []
let multiplier = 30 / groupSize**.4
v_max = 50 * multiplier
for (let i = 0; i < size; i++) {
let r = r_min
let c = blue
let v = createVector((v_min + random(-1.0, 1.0) * (v_max - v_min))*multiplier,
(v_min + random(-1.0, 1.0) * (v_max - v_min))*multiplier)
let p = createVector(random(width), random(height))
people[i] = new personClass(p, v, r, c, people, i)
}
}
function resetSim(newSize) {
clear()
days = 0
createGroup(newSize)
}
function createSliders(fee, size) {
// Create slider bars to adjust the parameters
fee_slider = createSlider(0, 0.4, fee, 0.01)
fee_slider.position(200, height + 10)
fee_slider.input(() => {
transactionFee = fee_slider.value()
})
groupSlider = createSlider(2, 1000, size, 1)
groupSlider.position(200, height + 40)
groupSlider.input(() => {
resetSim(groupSlider.value())
})
// Create labels to display the values of the parameters
let fee_label = createElement('p', 'Transaction fee: ')
fee_label.position(50, height - 10)
fee_label.style('font-size', '14pt')
let groupSizeLabel = createElement('p', 'Population: ')
groupSizeLabel.position(50, height + 20)
groupSizeLabel.style('font-size', '14pt')
let daysLabel = createElement('p', 'Days Elapsed: ')
daysLabel.position(500, height - 10)
daysLabel.style('font-size', '14pt')
let incomeLabel = createElement('p', 'Daily Income: ')
incomeLabel.position(500, height + 20)
incomeLabel.style('font-size', '14pt')
}
function updateLabels(start) {
if (!start) {
feeValue.remove()
groupValue.remove()
daysValue.remove()
incomeValue.remove()
}
// Create labels to display the values of the parameters
feeValue = createElement('p', (round(transactionFee * 100)).toString() + '%')
feeValue.position(350, height - 10)
feeValue.style('font-size', '14pt')
groupValue = createElement('p', groupSize.toString())
groupValue.position(350, height + 20)
groupValue.style('font-size', '14pt')
incomeValue = createElement('p', round(averageDailyIncome).toLocaleString('en-US', {style: 'currency', currency: 'USD', maximumFractionDigits: 0}))
incomeValue.position(650, height + 20)
incomeValue.style('font-size', '14pt')
daysValue = createElement('p', round(days).toString())
daysValue.position(650, height - 10)
daysValue.style('font-size', '14pt')
}
function draw() {
frame++
background(128)
let last_transactions = transactions
for (let i = 0; i < groupSize; i++) {
people[i].update()
people[i].display()
}
days_last_frame = (transactions - last_transactions) / groupSize / transactions_per_day
days += days_last_frame
// Distribute transaction fees collected this frame & slowly update average daily income
distributeFees(collectedFees);
if (days_last_frame > 0) {
averageDailyIncome = averageDailyIncome * 0.95 +
(collectedFees / groupSize / days_last_frame) * 0.05;
}
collectedFees = 0;
updateStats(frame)
drawGiniMeter()
}
function computeGiniIndex() {
//
// Uses discrete formula found at:
// https://en.wikipedia.org/wiki/Gini_coefficient#Calculation
//
averageBalance = 0
for (let i = 0; i < groupSize; i++) {
averageBalance += people[i].balance
if (people[i].balance < 0.0) {
fill(0)
noStroke()
text("Error: negative balance!", width/2, height/2)
}
}
averageBalance /= groupSize
let sumOfDifferences = 0
for (let i = 0; i < groupSize; i++) {
for (let j = 0; j < groupSize; j++) {
sumOfDifferences += abs(people[i].balance - people[j].balance)
}
}
return sumOfDifferences/(2 * groupSize * groupSize * averageBalance)
}
function updateStats(frame) {
textAlign(LEFT, BASELINE)
numRich = 0
numPoor = 0
moneySupply = 0
for (let i = 0; i < groupSize; i++) {
if (people[i].balance > averageBalance * 2) numRich++
else if (people[i].balance < averageBalance / 2) numPoor++
moneySupply += people[i].balance
}
if (frame % 100 == 0) {
// every once in a while, re-compute the giniIndex
giniIndex = computeGiniIndex()
if ((giniIndex > giniTarget) && !giniTargetDays) {
console.log("Gini target of " + giniTarget.toFixed(2) + " reached in " + days.toFixed(0) + " days");
giniTargetDays = days;
}
}
noStroke()
fill(128,128,128,220)
rect(0, height - 20, width, 20)
fill(0)
noStroke()
fill(green)
text(round(numRich/groupSize * 100)+"%", 10, height - 5)
fill(red)
text(round(numPoor/groupSize * 100)+"%", 40, height - 5)
fill(blue)
text(round((groupSize - numPoor - numRich)/groupSize * 100)+"%", 70, height - 5)
updateLabels(false)
}
function drawGiniMeter() {
translate(200, 380) // Shift the origin to the bottom edge
fill(128) // fill for the shapes
stroke(0) // Black stroke for the shapes
strokeWeight(2) // Thin stroke for the shapes
// Draw the dial arc
arc(200, 200, 300, 300, PI, 2*PI)
// Draw the red section between 0 and 100
fill(255, 0, 0) // Red fill for the section
// Use the speedToAngle function to calculate the start and end angles of the section
arc(200, 200, 200, 200, PI + giniToAngle(50), PI + giniToAngle(150))
// Draw the green section between 10 and 40
fill(0, 255, 0) // Green fill for the section
// Use the speedToAngle function to calculate the start and end angles of the section
arc(200, 200, 200, 200, PI + giniToAngle(60), PI + giniToAngle(90))
// Draw the yellow section between 40 and 60
fill(255, 255, 0) // Green fill for the section
// Use the speedToAngle function to calculate the start and end angles of the section
arc(200, 200, 200, 200, PI + giniToAngle(90), PI + giniToAngle(110))
// Draw the tick marks and the labels
for (let i = 0; i <= 10; i++) {
// Calculate the angle and the position of each tick mark
// Change the formula to add the mapped angle to PI/2
let angle = 3*PI/2 + giniToAngle(i * 10)
let x1 = 200 + 140 * cos(angle)
let y1 = 200 + 140 * sin(angle)
let x2 = 200 + 150 * cos(angle)
let y2 = 200 + 150 * sin(angle)
// Draw the tick mark
line(x1, y1, x2, y2)
// Draw the label
textSize(12)
textAlign(CENTER, CENTER)
fill(0)
let x3 = 200 + 125 * cos(angle)
let y3 = 200 + 125 * sin(angle)
text(i * 10, x3, y3)
}
// Draw the needle
stroke(0, 0, 0) // Black stroke for the needle
strokeWeight(4) // Thick stroke for the needle
// Change the formula to add the mapped angle to PI/2
let angle = 3*PI/2 + giniToAngle(round(giniIndex * 100,0))
let x4 = 200 + 105 * cos(angle) // Calculate the x-coordinate of the tip of the needle
let y4 = 200 + 105 * sin(angle) // Calculate the y-coordinate of the tip of the needle
line(200, 200, x4, y4) // Draw the needle
// Draw the center circle
fill(0) // Black fill for the center circle
strokeWeight(2) // Thin stroke for the center circle
circle(200, 200, 10) // Draw the center circle
strokeWeight(1)
text('Gini (inequality) Index', 200, 212)
}
// person class
function personClass(p, v, r, c, _others, _id) {
this.p = p.copy()
this.v = v.copy()
this.r = r
this.c = c
this.others = _others
this.id = _id
this.dt = 1.0/60.0
this.balance = balance_initial
this.productivity = randomGaussian(0, stdevProductivity);
//console.log(this.productivity);
this.dead = false
this.update = function() {
if (frozen) return
if (collisions) {
// Check for collisions and add restoring forces
let collisionForces = createVector(0,0,0)
for (let i = 0; i < this.others.length; i++) {
if (!this.dead && !this.others[i].dead) {
if (i != this.id) {
let d = p5.Vector.sub(this.p, this.others[i].p)
let penetration = ((this.r + this.others[i].r)) - d.mag()
if (penetration > 0) {
d.normalize()
// Elastic component - restitution (note: does not conserve energy)
collisionForces.add(d.mult(10 * penetration))
// When in collision, after first few secs, there may be trade
maybeTrade(this.id, i)
}
}
}
}
this.v.add(collisionForces.mult(1.0))
}
if (this.v.mag() > v_max) this.v.mult(v_max / this.v.mag())
// Your radius corresponds to your fraction of the money supply
this.r = max(sqrt(this.balance / moneySupply * width * height * adjustRadius), minRadius)
let average_balance = moneySupply / groupSize
this.p.add(p5.Vector.mult(this.v, this.dt))
if (this.balance < average_balance / 2) this.c = red
else if (this.balance > average_balance * 2) this.c = green
else this.c = blue
// Edge conditions
if (repeat) {
if (this.p.x > width) this.p.x -= width
if (this.p.x < 0) this.p.x += width
if (this.p.y > height) this.p.y -= height
if (this.p.y < 0) this.p.y += height
} else {
if (this.p.x >= width) {
this.v.x *= -1.0
this.p.x = width - 1
}
if (this.p.x < 0) {
this.v.x *= -1.0
this.p.x = 0.0
}
if (this.p.y >= height) {
this.v.y *= -1.0
this.p.y = height - 1
}
if (this.p.y < 0) {
this.v.y *= -1.0
this.p.y = 0.0
}
}
}
this.display = function() {
stroke(0)
//strokeWeight(1)
noStroke()
fill(this.c)
ellipse(this.p.x, this.p.y, this.r * 2, this.r * 2)
}
}
function mousePressed() {
if (mouseY < height) {
// If mouse clicked anywhere in simulation area, stop or start the simulation
frozen = !frozen;
}
}