Introduction    

For those who have lived through a pandemic, price volatility is no new phenomenon. In the early days of lockdown restrictions, dramatic images of empty shelves surfaced across media outlets, prompting fears of widespread shortages and price increases. Economic theory provides a standard explanation: a change in consumer preferences (as the jargon goes) results in a rightwards shift in demand. This leads to shortages, which spurs a response in producers. They increase quantity supplied and raise prices. Such conditions commonly occur in commodity markets, defined as markets for economic goods whose individual units are interchangeable and indistinguishable. Common commodities include corn, sugar, and gold.

The size of price increase depends on the price elasticity of supply and demand. In intuitive terms, the price elasticity of supply measures how easily producers can increase or decrease production in response to changes in demand. For instance, if everyone wants to buy a house, it takes time for homebuilders to respond and increase construction. It takes even longer for those houses to come onto the market.

The price elasticity of demand, meanwhile, measures how responsive consumers are to changes in price. For example, most households demand a roughly fixed amount of electricity each month. Even if the price of electricity increases, consumers will demand the same quantity of electricity. Most people cannot change their electric lighting to gas lamps overnight, and nor would they want to. 

For price inelastic goods, any change in the market will result in substantial price fluctuations, harmful to both producers and consumers alike. If I can neither reduce the amount of electricity I need nor switch to an alternative, all I can do is raise the price I am willing to pay for electricity. If property developers cannot build new houses quickly enough, all they can do is raise house prices.

Price fluctuations are a special type of market failure. Although the market delivers an efficient outcome, the end results are undesirable. In times of shortages and high prices, poorer consumers spend much of the resources they have on essentials, reducing consumption at great personal cost. Wealthier consumers reduce consumption very little even when prices soar. Conversely, in times of plenty, producer incomes will shrink to unprofitable margins, potentially leading to a missing market or oligopolistic market conditions as the majority of producers switch to other industries.

Uncertainty over the market price leads to risk. Historical evidence suggests this uncertainty facilitates speculation, amplifying price volatility through the accumulation of speculative bubbles. Furthermore, systemic risk makes it difficult to plan investment in new productive capacity or future patterns of consumption. Although some industries, such as mining, have sustained massive capital expenditure alongside price fluctuations, price volatility still represents an additional cost to producers.

Any initial price instability may lead to long-term price volatility, as there is a tendency for the market to overreact to the initial price shock. Data from the Office for National Statistics suggests that this is a realistic prediction. The graph below shows a selection of three goods from an experimental index of online prices for “high-demand” products. The introduction of lockdown across the UK coincides with a general price increase for all high-demand products (all HDPs), spiking on 20 April 2020 by over 2%. Subsequently, this fell to almost 3% below the base value by July 20. We observe, however, that the price of dried pasta corrects to pre-pandemic levels; interestingly, the price of flour remains an anomaly in the time series, showing a sustained increase of 0.08% every week, with respect to the base date.

Figure 1. Source: Office for National Statistics, Dataset: Online price changes for high-demand products. Graphics: Chenyang Li, via matplotlib.

The Model

Pivoting away from textbook economics, we now reformulate such modelling towards a more fundamental, participant-based model of the market.

Much of economics centres on two axioms:

  • The optimisation principle: people try to choose the best patterns of consumption that they can afford.
  • The equilibrium principle: prices tend to adjust until the quantity demanded equals the quantity supplied.

Precisely how equilibrium arises receives less attention in the A Level syllabus, and any explanatory adjustment process often assumes perfect information. Alternative assumptions will necessitate explicit modelling of individual agents and their behaviour, which greatly increases the level of complication of the model.

The model simulates a closed market for an arbitrary commodity. In it, we instantiate producers and consumers, each buying/selling one unit of the good for an arbitrary timeframe. Consumers will seek to buy at as low a price as possible, whereas producers will sell at as high a price as possible. Both adjust prices each time they succeed or consecutively fail, and have a maximum and minimum price limit, respectively.

For computational simplicity, we designed the transaction mechanism in a similar way to a first-price sealed-bid (or blind auction). The highest bid wins and the winner pays a price equal to his/her bid. Producers are assigned a pool of consumers and will make a transaction if the price is suitable to both parties. The order of search is random.

During a transaction, consumers will:

  • not buy if the offered price is too high.
  • lower prices if they succeed.
  • raise prices if they fail consecutively for a tolerated amount of days (this is represented as the variable “consumer_tolerance” in our code). They will raise prices until they are at their maximum price.
  • leave the market if they continuously fail to purchase (this is “threshold” in the code).

Producers will:

  • sell for the best price, given a pool of consumers.
  • not sell if the best price is below their current acceptance price.
  • raise prices if a transaction occurred.
  • lower prices if they fail consecutively, until they are at their minimum price.
  • leave the market if they continuously fail to sell.

Below is the supply and demand diagram for our modelled market.

Figure 2. Graphics: Chenyang Li, via matplotlib.

Under the same conditions, a test run of the model matches the predicted equilibrium price of the supply-demand graph. Since quantity is discrete in our simulation, the supply and demand graph predicts an equilibrium quantity of either 26 or 27, and an equilibrium price between 685-770, depending on the aggressiveness of participants. The estimated market equilibrium of our model is a price of 750 and quantity of 27, both to 2 significant figures.

Figure 3. Variation of the market price over time with no changes to the system. Both graphs clearly show average prices settling to an equilibrium price after approximately 50 days. Graphics: Chenyang Li, via matplotlib.

Simulation

We now run the model under the following conditions, for 1000 days (note that price and quantity are unitless):

  • 50 consumers, offering an average price of 750 with standard deviation 200. Standard deviation is the mathematical way of expressing how spread out prices out. A typical price will be in the range 550-950; the average of all prices is set to 750. 
  • 30 producers, accepting an average price of 500 with standard deviation 200.

Then:

  • After day 250, consumers panic. They really “want” the good and correspondingly raise their tolerated maximum price by 500.
  • After day 500, additional producers (with an average price of 400) flood into the market because of high prices.
  • After day 750, additional consumers enter the market because of low prices.

Simulations ran under two assumptions:

  • Each transaction made per day remained closed for the remainder of that iteration (successful participants retire until the next day).
  • There is no incumbency (the possibility of extending transactions to future periods).

Results

The graph below shows our model of price volatility.

Figure 4. After each disturbance, the market mechanism will cause prices to adjust to a new equilibrium, causing significant price fluctuations. Graphics: Chenyang Li, via matplotlib.

From Figure 4, we see our results are exactly what theory and empirical data predicts: sudden price changes may lead to large overcorrections by transacting participants, turning short-term panics into long-term price volatility. With a range of 800, prices in our simulated market are wild.

However, there are caveats in our simulation, as we have made simplifying modelling assumptions that may prove to be unrealistic. Most notably, the transaction mechanism is based on a blind auction for a good sold in indivisible units, of which participants can only ever sell or buy one. This simplification means that our model will not capture the “hoarding” behaviour witnessed in demand-side panics, although it remains unclear what implications this omission may have for the accuracy of our model.

Further Development

A natural extension of the project would be to allow transactions of multiple quantities, examining how this will change the simulated outcome. Furthermore, we would conduct a rigorous evaluation of our results against empirical data, refining the model towards accurately predicting real-world situations. Due to the chaotic nature of human interactions, however, we would expect significant deviation between our results and reality no matter how accurate the model is, especially given that the initial conditions for real consumers and producers can only be roughly measured, and constantly change over time.

The assumptions behind this model are restrictive: it represents not much more than a thought experiment, or an introductory foray into economic and mathematical modelling. Future iterations of the model will need to challenge these assumptions to produce substantive results beyond the realms of traditional economic thought.

A more in-depth version of this post can be found below, complete with references:

The full code for the model can be found below:

# 12/04/2021
# Author: Chenyang Li

# This is a simple numerical model of a market for an arbitrary economic good.
# A Consumer and Producer class are instantiated and put through a market simulation.
# Producers are randomly assigned a pool of consumers and will make a transaction
# if the price is suitable to both parties.
# Order of search is random.
# Results are shown in a matplotlib plot.

import matplotlib.pyplot as plt
import numpy as np

# hyperparameters
days = 1000
aggression_factor = 0.8  # ratio of rate of change of new price, 0<x<1
threshold = 100  # number of days after which producers/consumers withdraw after successive failure

panic_day = 250  # day after which consumers panic and want more
overcompensate_day = 500  # day after which producers overcompensate
more_consumers_day = 750  # day after which more consumers flood in to the market

producer_tolerance = 5  # days after which producers will decrease prices
consumer_tolerance = 0  # days after which consumers will increase prices

np.random.seed(10)  # fixed random seed for reproducibility


class Consumer:
    """
    Consumer object that models a simplistic rational consumer.

    Consumers will:
    1. not buy if the price offered is too high
    2. lower prices if a transaction occurred
    3. raise prices if no transaction in two previous days, until price == max_price
    """

    def __init__(self, max_price, price, success=False, attempts=0):
        """
        :param max_price: maximum price a consumer will tolerate
        :param price: current offered price
        :param success: whether the previous purchase was a success
        :param attempts: # of attempts without success. Resets to 0 after transaction completed
        """
        if max_price < price:
            raise ValueError("max_price must be greater than price")

        self.max_price = max_price
        self.price = price
        self.success = success
        self.attempts = attempts
        self.price_inc = max(max_price - price, 100)  # price_inc: increment of price change.
        self.price_inc_list = [self.price_inc]
        self.price_list = [price]
        self.attempts_list = [attempts]

    def __str__(self):
        return f"Consumer. Current offered price: {self.price}. Maximum price: {self.max_price}."

    def set_price(self):
        """
        Method that determines how producers change their prices
        :global aggression_factor: hyperparameter determining how price_change changes (second derivative)
        """
        self.attempts += 1  # increase attempts by 1
        if self.success:
            new_price = self.price - self.price_inc
            if new_price < 0:
                self.price = 0
            else:
                self.price = new_price
            # increment of price change is reduced every time a successful transaction is made
            self.price_inc = int(self.price_inc * aggression_factor)  # by a ratio of aggression_factor
            if self.price_inc < 1:
                # |price_change| >= 1
                self.price_inc = 1
            self.attempts = 0  # reset attempts
        else:
            if self.attempts > consumer_tolerance:
                new_price = self.price + self.price_inc
                if new_price <= self.max_price:
                    # prices must not exceed the maximum price limit
                    self.price = new_price
                else:
                    self.price = self.max_price
            else:
                # price increases only after x consecutive days of failure
                pass

        self.price_list.append(self.price)
        self.price_inc_list.append(self.price_inc)
        self.attempts_list.append(self.attempts)


class Producer:
    """
    Producer object that models a simplistic profit-maximising producer.

    Producers will:
    1. sell to the best price, given a pool of Consumers.
    2. not sell if the best price is below the current acceptance price
    3. raise prices if a transaction occurred
    4. lower prices if no transaction
    """

    def __init__(self, min_price, price, success=False, attempts=0):
        """
        :param min_price: minimum price a consumer will tolerate
        :param price: current acceptance price
        :param success: whether the previous purchase was a success
        :param attempts: # of attempts without success. Resets to 0 after transaction completed
        """
        if min_price > price:
            raise ValueError("min_price must be less than price")

        self.min_price = min_price
        self.price = price
        self.success = success
        self.attempts = attempts
        self.price_inc = max(price - min_price, 100)  # price_inc: increment of price change.
        self.price_inc_list = [self.price_inc]
        self.price_list = [price]

    def __str__(self):
        return f"Producer. Current acceptance price: {self.price}. Minimum price: {self.min_price}."

    def set_price(self):
        """
        Method that determines how producers change their prices
        """
        self.attempts += 1  # increase attempts by 1
        if self.success:
            self.price += self.price_inc
            # increment of price change is reduced every time a successful transaction is made
            self.price_inc = int(self.price_inc * aggression_factor)  # by a ratio of aggression_factor
            if self.price_inc < 1:
                # |price_change| >= 1
                self.price_inc = 1
            self.attempts = 0  # reset attempts
        else:
            if self.attempts > producer_tolerance:
                # producers reduce prices if they cannot sell
                new_price = self.price - self.price_inc
                if new_price >= self.min_price:
                    # prices must not exceed the minimum price limit
                    self.price = new_price
                else:
                    self.price = self.min_price
            else:
                # price increases only after x consecutive days of failure
                pass

        self.price_list.append(self.price)
        self.price_inc_list.append(self.price_inc)


class Government:
    def __init__(self, sell_price=0, buy_price=0):
        self.sell_price = sell_price
        self.buy_price = buy_price
        self.quantity = 0
        self.cost = 0

    def buy(self):
        self.quantity += 1
        self.cost += self.buy_price

    def sell(self):
        self.quantity -= 1
        if self.quantity < 0:
            raise ValueError("Government quantity >= 0")
        self.cost -= self.sell_price


def populate_consumers(mu, sigma, n, time=0):
    """
    Populate a group of consumers with normally distributed max_prices and random prices.
    The normal distribution is chosen as it is a good approximation for unknown
    distributions of continuous variables in real life.
    See: Central Limit Theorem

    :param mu: population mean
    :param sigma: standard deviation
    :param n: number of consumers
    :param time: if additional consumers join, their average prices before must be filled in
    :return consumers: list of Consumer objects
    """
    consumers = []
    max_prices = list(np.random.normal(mu, sigma, n))
    max_prices = [int(price) for price in max_prices]  # round to int
    initial_prices_seed = list(np.random.random(n))
    for index, max_price in enumerate(max_prices):
        price = int(max_price * initial_prices_seed[index])
        instance = Consumer(max_price, price)
        consumers.append(instance)

    if time != 0:
        filler = np.empty(time)
        filler.fill(np.nan)
        filler = list(filler)
        for consumer in consumers:
            consumer.price_list = filler + consumer.price_list

    return consumers


def populate_producers(mu, sigma, n, time=0):
    """
    Populate a group of producers  with normally distributed min_prices and random prices.
    The normal distribution is chosen as it is a good approximation for unknown
    distributions of continuous variables in real life.
    See: Central Limit Theorem

    :param mu: population mean
    :param sigma: standard deviation
    :param n: number of producers
    :param time: if additional producers join, their average prices before must be filled in
    :return producers : list of Producer objects
    """
    producers = []
    min_prices = list(np.random.normal(mu, sigma, n))
    min_prices = [int(price) for price in min_prices]  # round to int
    initial_prices_seed = list(np.random.random(n))
    for index, min_price in enumerate(min_prices):
        # prices will not be greater 2 times min_price
        price = int(min_price * (1 + initial_prices_seed[index]))
        instance = Producer(min_price, price)
        producers.append(instance)

    if time != 0:
        filler = np.empty(time)
        filler.fill(np.nan)
        filler = list(filler)
        for producer in producers:
            producer.price_list = filler + producer.price_list

    return producers


def plot_supply_demand(producers, consumers):
    """
    :param producers: list of Producer objects
    :param consumers: list of Consumer objects
    """
    consumers.sort(key=lambda consumer: consumer.max_price, reverse=True)  # sort consumers by price descending
    producers.sort(key=lambda producer: producer.min_price)  # sort producers by price ascending

    c_prices, c_quantity = [], []
    for num, consumer in enumerate(consumers):
        if consumer.max_price not in c_prices:
            c_prices.append(consumer.max_price)
            c_quantity.append(num)

    p_prices, p_quantity = [], []
    for num, producer in enumerate(producers):
        if producer.min_price not in c_prices:
            p_prices.append(producer.min_price)
            p_quantity.append(num)

    plt.plot(c_quantity, c_prices, color="r", linestyle="--", linewidth=0.7, label="Demand $D$")
    plt.plot(p_quantity, p_prices, color="b", linestyle="--", linewidth=0.7, label="Supply $S$")
    plt.title("Our Simulated Market")
    plt.xlabel("Quantity $q$")
    plt.ylabel("Price $p$")
    plt.legend()
    plt.grid(color='grey', linestyle='--', linewidth=0.1)

    plt.xlim(0, 50)
    plt.ylim(0)

    plt.show()


def simulate_market(producers, consumers, transaction_prices):
    """
    IMPORTANT FUNCTION
    Simulates the market
    :param consumers: list of Consumer objects
    :param producers: list of Producer objects
    :param transaction_prices: history of successful transaction prices
    :return: producers, consumers, transaction_prices (all updated)
    """
    consumers.sort(key=lambda consumer: consumer.price, reverse=True)  # sort consumers by price descending
    np.random.shuffle(producers)  # shuffle producers
    prices_today = []  # initialise

    index = 0  # search index for consumers (consumers is sorted, so index denotes best price)
    for num, producer in enumerate(producers):
        if index == len(consumers):
            # all consumers have purchased
            break

        if consumers[index].price >= producer.price:
            producer.success = True
            consumers[index].success = True
            prices_today.append(consumers[index].price)
            index += 1
        else:
            producer.success = False
            consumers[index].success = False

    new_consumers = []
    # once transactions are all made consumers set prices
    for num, consumer in enumerate(consumers):
        if num >= index:
            consumer.success = False
        consumer.set_price()

        if consumer.attempts < threshold:
            new_consumers.append(consumer)
        else:
            if (consumer.max_price - consumer.price) <= consumer.price_inc:
                # consumer leaves market if they fail consistently
                # however, they must be close to the bottom of their price range
                continue
            else:
                new_consumers.append(consumer)

    new_producers = []
    # once transactions are all made producer set prices
    for num, producer in enumerate(producers):
        if num >= index:
            producer.success = False
        producer.set_price()

        if producer.attempts < threshold:
            new_producers.append(producer)
        else:
            if (producer.price - producer.min_price) <= producer.price_inc:
                # producer leaves market if they fail consistently
                # however, they must be close to the bottom of their price range
                continue
            else:
                new_producers.append(producer)

    if not prices_today:
        # no successful transactions means the list is empty
        transaction_prices.append(np.nan)
    else:
        prices_today = np.array(prices_today)
        transaction_prices.append(prices_today.mean())

    return new_producers, new_consumers, transaction_prices


def reset_price_inc(producers, consumers, price):
    """
    Reset price increment because of anticipated shock.
    :param producers: list of Producer objects
    :param consumers: list of Consumer objects
    :param price: anticipated price change
    :return: producers, consumers
    """
    for consumer in consumers:
        consumer.price_inc = max(price // 2, consumer.price_inc)
    for producer in producers:
        producer.price_inc = max(price // 2, producer.price_inc)
    return producers, consumers


def increase_price(producers, consumers, which="consumer", price=500):
    """
    Increase max_price for Consumers, min_price for Producers
    :param producers: list of Producer objects
    :param consumers: list of Consumer objects
    :param which: whether consumers or producers increase prices
    :param price: price change upwards
    :return: producer, consumers
    """
    if which == "consumer":
        for consumer in consumers:
            consumer.max_price += price
    elif which == "producer":
        for producer in producers:
            producer.min_price += price
    else:
        raise ValueError("Variable 'which' must be either 'consumer' or 'producer'")

    producers, consumers = reset_price_inc(producers, consumers, price)
    return producers, consumers


def decrease_price(producers, consumers, which="consumer", price=500):
    """
    Decrease max_price for Consumers, min_price for Producers
    :param producers: list of Producer objects
    :param consumers: list of Consumer objects
    :param which: whether consumers or producers increase prices
    :param price: price change downwards
    :return: producer, consumers
    """
    if which == "consumers":
        for consumer in consumers:
            consumer.max_price -= price
    elif which == "producers":
        for producer in producers:
            producer.min_price -= price
    else:
        raise ValueError("Variable 'which' must be either 'consumer' or 'producer'")

    producers, consumers = reset_price_inc(producers, consumers, price)
    return producers, consumers


def main():
    """
    Main function
    """
    consumers = populate_consumers(750, 200, 50)
    producers = populate_producers(500, 150, 30)

    plot_supply_demand(producers, consumers)

    gov = Government(800, 600)

    # the simulation
    price_history = []  # initialise
    for day in range(0, days):
        if day == panic_day:
            producers, consumers = increase_price(producers, consumers, which="consumer", price=500)
        if day == overcompensate_day:
            extra_producers = populate_producers(400, 100, 30, time=day)
            producers += extra_producers
            reset_price_inc(producers, consumers, price=400)
        if day == more_consumers_day:
            extra_consumers = populate_consumers(1000, 300, 20, time=day)
            consumers += extra_consumers
            reset_price_inc(producers, consumers, price=400)

        producers, consumers, price_history = simulate_market(producers, consumers, price_history)

    # get mean producer price
    producer_price = []  # initialise
    for producer in producers:
        print(producer)
        producer_price.append(producer.price_list)
    producer_price = np.array(producer_price)
    print(producer_price)
    mean_producer_price = np.nanmean(producer_price, axis=0)


    # get mean consumer price
    consumer_price = []  # initialise
    for consumer in consumers:
        print(consumer)
        consumer_price.append(consumer.price_list)
    consumer_price = np.array(consumer_price)
    mean_consumer_price = np.nanmean(consumer_price, axis=0)

    plt.subplot(2, 1, 1)
    plt.plot(mean_consumer_price, color="r", linestyle="--", linewidth=0.7, label="Mean consumer price")
    plt.plot(mean_producer_price, color="b", linestyle="--", linewidth=0.7, label="Mean producer price")
    plt.ylabel('Price')
    plt.legend()

    plt.subplot(2, 1, 2)
    plt.plot(price_history, color="black", linestyle=":", linewidth=0.7, label="Market price", marker=".", ms=0.7)

    plt.xlabel("Time/days")
    plt.ylabel("Price")
    plt.legend()

    plt.suptitle("Market Price Against Time")
    plt.show()


main()
# 12/04/2021
# Author: Chenyang Li

# This is a simple numerical model of a market for an arbitrary economic good.
# A Consumer and Producer class are instantiated and put through a market simulation.
# Producers are randomly assigned a pool of consumers and will make a transaction
# if the price is suitable to both parties.
# Order of search is random.
# Results are shown in a matplotlib plot.

import matplotlib.pyplot as plt
import numpy as np

# hyperparameters
days = 1000
aggression_factor = 0.8  # ratio of rate of change of new price, 0<x<1
threshold = 100  # number of days after which producers/consumers withdraw after successive failure

panic_day = 250  # day after which consumers panic and want more
overcompensate_day = 500  # day after which producers overcompensate
more_consumers_day = 750  # day after which more consumers flood in to the market

producer_tolerance = 5  # days after which producers will decrease prices
consumer_tolerance = 0  # days after which consumers will increase prices

np.random.seed(10)  # fixed random seed for reproducibility


class Consumer:
    """
    Consumer object that models a simplistic rational consumer.

    Consumers will:
    1. not buy if the price offered is too high
    2. lower prices if a transaction occurred
    3. raise prices if no transaction in two previous days, until price == max_price
    """

    def __init__(self, max_price, price, success=False, attempts=0):
        """
        :param max_price: maximum price a consumer will tolerate
        :param price: current offered price
        :param success: whether the previous purchase was a success
        :param attempts: # of attempts without success. Resets to 0 after transaction completed
        """
        if max_price < price:
            raise ValueError("max_price must be greater than price")

        self.max_price = max_price
        self.price = price
        self.success = success
        self.attempts = attempts
        self.price_inc = max(max_price - price, 100)  # price_inc: increment of price change.
        self.price_inc_list = [self.price_inc]
        self.price_list = [price]
        self.attempts_list = [attempts]

    def __str__(self):
        return f"Consumer. Current offered price: {self.price}. Maximum price: {self.max_price}."

    def set_price(self):
        """
        Method that determines how producers change their prices
        :global aggression_factor: hyperparameter determining how price_change changes (second derivative)
        """
        self.attempts += 1  # increase attempts by 1
        if self.success:
            new_price = self.price - self.price_inc
            if new_price < 0:
                self.price = 0
            else:
                self.price = new_price
            # increment of price change is reduced every time a successful transaction is made
            self.price_inc = int(self.price_inc * aggression_factor)  # by a ratio of aggression_factor
            if self.price_inc < 1:
                # |price_change| >= 1
                self.price_inc = 1
            self.attempts = 0  # reset attempts
        else:
            if self.attempts > consumer_tolerance:
                new_price = self.price + self.price_inc
                if new_price <= self.max_price:
                    # prices must not exceed the maximum price limit
                    self.price = new_price
                else:
                    self.price = self.max_price
            else:
                # price increases only after x consecutive days of failure
                pass

        self.price_list.append(self.price)
        self.price_inc_list.append(self.price_inc)
        self.attempts_list.append(self.attempts)


class Producer:
    """
    Producer object that models a simplistic profit-maximising producer.

    Producers will:
    1. sell to the best price, given a pool of Consumers.
    2. not sell if the best price is below the current acceptance price
    3. raise prices if a transaction occurred
    4. lower prices if no transaction
    """

    def __init__(self, min_price, price, success=False, attempts=0):
        """
        :param min_price: minimum price a consumer will tolerate
        :param price: current acceptance price
        :param success: whether the previous purchase was a success
        :param attempts: # of attempts without success. Resets to 0 after transaction completed
        """
        if min_price > price:
            raise ValueError("min_price must be less than price")

        self.min_price = min_price
        self.price = price
        self.success = success
        self.attempts = attempts
        self.price_inc = max(price - min_price, 100)  # price_inc: increment of price change.
        self.price_inc_list = [self.price_inc]
        self.price_list = [price]

    def __str__(self):
        return f"Producer. Current acceptance price: {self.price}. Minimum price: {self.min_price}."

    def set_price(self):
        """
        Method that determines how producers change their prices
        """
        self.attempts += 1  # increase attempts by 1
        if self.success:
            self.price += self.price_inc
            # increment of price change is reduced every time a successful transaction is made
            self.price_inc = int(self.price_inc * aggression_factor)  # by a ratio of aggression_factor
            if self.price_inc < 1:
                # |price_change| >= 1
                self.price_inc = 1
            self.attempts = 0  # reset attempts
        else:
            if self.attempts > producer_tolerance:
                # producers reduce prices if they cannot sell
                new_price = self.price - self.price_inc
                if new_price >= self.min_price:
                    # prices must not exceed the minimum price limit
                    self.price = new_price
                else:
                    self.price = self.min_price
            else:
                # price increases only after x consecutive days of failure
                pass

        self.price_list.append(self.price)
        self.price_inc_list.append(self.price_inc)


class Government:
    def __init__(self, sell_price=0, buy_price=0):
        self.sell_price = sell_price
        self.buy_price = buy_price
        self.quantity = 0
        self.cost = 0

    def buy(self):
        self.quantity += 1
        self.cost += self.buy_price

    def sell(self):
        self.quantity -= 1
        if self.quantity < 0:
            raise ValueError("Government quantity >= 0")
        self.cost -= self.sell_price


def populate_consumers(mu, sigma, n, time=0):
    """
    Populate a group of consumers with normally distributed max_prices and random prices.
    The normal distribution is chosen as it is a good approximation for unknown
    distributions of continuous variables in real life.
    See: Central Limit Theorem

    :param mu: population mean
    :param sigma: standard deviation
    :param n: number of consumers
    :param time: if additional consumers join, their average prices before must be filled in
    :return consumers: list of Consumer objects
    """
    consumers = []
    max_prices = list(np.random.normal(mu, sigma, n))
    max_prices = [int(price) for price in max_prices]  # round to int
    initial_prices_seed = list(np.random.random(n))
    for index, max_price in enumerate(max_prices):
        price = int(max_price * initial_prices_seed[index])
        instance = Consumer(max_price, price)
        consumers.append(instance)

    if time != 0:
        filler = np.empty(time)
        filler.fill(np.nan)
        filler = list(filler)
        for consumer in consumers:
            consumer.price_list = filler + consumer.price_list

    return consumers


def populate_producers(mu, sigma, n, time=0):
    """
    Populate a group of producers  with normally distributed min_prices and random prices.
    The normal distribution is chosen as it is a good approximation for unknown
    distributions of continuous variables in real life.
    See: Central Limit Theorem

    :param mu: population mean
    :param sigma: standard deviation
    :param n: number of producers
    :param time: if additional producers join, their average prices before must be filled in
    :return producers : list of Producer objects
    """
    producers = []
    min_prices = list(np.random.normal(mu, sigma, n))
    min_prices = [int(price) for price in min_prices]  # round to int
    initial_prices_seed = list(np.random.random(n))
    for index, min_price in enumerate(min_prices):
        # prices will not be greater 2 times min_price
        price = int(min_price * (1 + initial_prices_seed[index]))
        instance = Producer(min_price, price)
        producers.append(instance)

    if time != 0:
        filler = np.empty(time)
        filler.fill(np.nan)
        filler = list(filler)
        for producer in producers:
            producer.price_list = filler + producer.price_list

    return producers


def plot_supply_demand(producers, consumers):
    """
    :param producers: list of Producer objects
    :param consumers: list of Consumer objects
    """
    consumers.sort(key=lambda consumer: consumer.max_price, reverse=True)  # sort consumers by price descending
    producers.sort(key=lambda producer: producer.min_price)  # sort producers by price ascending

    c_prices, c_quantity = [], []
    for num, consumer in enumerate(consumers):
        if consumer.max_price not in c_prices:
            c_prices.append(consumer.max_price)
            c_quantity.append(num)

    p_prices, p_quantity = [], []
    for num, producer in enumerate(producers):
        if producer.min_price not in c_prices:
            p_prices.append(producer.min_price)
            p_quantity.append(num)

    plt.plot(c_quantity, c_prices, color="r", linestyle="--", linewidth=0.7, label="Demand $D$")
    plt.plot(p_quantity, p_prices, color="b", linestyle="--", linewidth=0.7, label="Supply $S$")
    plt.title("Our Simulated Market")
    plt.xlabel("Quantity $q$")
    plt.ylabel("Price $p$")
    plt.legend()
    plt.grid(color='grey', linestyle='--', linewidth=0.1)

    plt.xlim(0, 50)
    plt.ylim(0)

    plt.show()


def simulate_market(producers, consumers, transaction_prices):
    """
    IMPORTANT FUNCTION
    Simulates the market
    :param consumers: list of Consumer objects
    :param producers: list of Producer objects
    :param transaction_prices: history of successful transaction prices
    :return: producers, consumers, transaction_prices (all updated)
    """
    consumers.sort(key=lambda consumer: consumer.price, reverse=True)  # sort consumers by price descending
    np.random.shuffle(producers)  # shuffle producers
    prices_today = []  # initialise

    index = 0  # search index for consumers (consumers is sorted, so index denotes best price)
    for num, producer in enumerate(producers):
        if index == len(consumers):
            # all consumers have purchased
            break

        if consumers[index].price >= producer.price:
            producer.success = True
            consumers[index].success = True
            prices_today.append(consumers[index].price)
            index += 1
        else:
            producer.success = False
            consumers[index].success = False

    new_consumers = []
    # once transactions are all made consumers set prices
    for num, consumer in enumerate(consumers):
        if num >= index:
            consumer.success = False
        consumer.set_price()

        if consumer.attempts < threshold:
            new_consumers.append(consumer)
        else:
            if (consumer.max_price - consumer.price) <= consumer.price_inc:
                # consumer leaves market if they fail consistently
                # however, they must be close to the bottom of their price range
                continue
            else:
                new_consumers.append(consumer)

    new_producers = []
    # once transactions are all made producer set prices
    for num, producer in enumerate(producers):
        if num >= index:
            producer.success = False
        producer.set_price()

        if producer.attempts < threshold:
            new_producers.append(producer)
        else:
            if (producer.price - producer.min_price) <= producer.price_inc:
                # producer leaves market if they fail consistently
                # however, they must be close to the bottom of their price range
                continue
            else:
                new_producers.append(producer)

    if not prices_today:
        # no successful transactions means the list is empty
        transaction_prices.append(np.nan)
    else:
        prices_today = np.array(prices_today)
        transaction_prices.append(prices_today.mean())

    return new_producers, new_consumers, transaction_prices


def reset_price_inc(producers, consumers, price):
    """
    Reset price increment because of anticipated shock.
    :param producers: list of Producer objects
    :param consumers: list of Consumer objects
    :param price: anticipated price change
    :return: producers, consumers
    """
    for consumer in consumers:
        consumer.price_inc = max(price // 2, consumer.price_inc)
    for producer in producers:
        producer.price_inc = max(price // 2, producer.price_inc)
    return producers, consumers


def increase_price(producers, consumers, which="consumer", price=500):
    """
    Increase max_price for Consumers, min_price for Producers
    :param producers: list of Producer objects
    :param consumers: list of Consumer objects
    :param which: whether consumers or producers increase prices
    :param price: price change upwards
    :return: producer, consumers
    """
    if which == "consumer":
        for consumer in consumers:
            consumer.max_price += price
    elif which == "producer":
        for producer in producers:
            producer.min_price += price
    else:
        raise ValueError("Variable 'which' must be either 'consumer' or 'producer'")

    producers, consumers = reset_price_inc(producers, consumers, price)
    return producers, consumers


def decrease_price(producers, consumers, which="consumer", price=500):
    """
    Decrease max_price for Consumers, min_price for Producers
    :param producers: list of Producer objects
    :param consumers: list of Consumer objects
    :param which: whether consumers or producers increase prices
    :param price: price change downwards
    :return: producer, consumers
    """
    if which == "consumers":
        for consumer in consumers:
            consumer.max_price -= price
    elif which == "producers":
        for producer in producers:
            producer.min_price -= price
    else:
        raise ValueError("Variable 'which' must be either 'consumer' or 'producer'")

    producers, consumers = reset_price_inc(producers, consumers, price)
    return producers, consumers


def main():
    """
    Main function
    """
    consumers = populate_consumers(750, 200, 50)
    producers = populate_producers(500, 150, 30)

    plot_supply_demand(producers, consumers)

    gov = Government(800, 600)

    # the simulation
    price_history = []  # initialise
    for day in range(0, days):
        if day == panic_day:
            producers, consumers = increase_price(producers, consumers, which="consumer", price=500)
        if day == overcompensate_day:
            extra_producers = populate_producers(400, 100, 30, time=day)
            producers += extra_producers
            reset_price_inc(producers, consumers, price=400)
        if day == more_consumers_day:
            extra_consumers = populate_consumers(1000, 300, 20, time=day)
            consumers += extra_consumers
            reset_price_inc(producers, consumers, price=400)

        producers, consumers, price_history = simulate_market(producers, consumers, price_history)

    # get mean producer price
    producer_price = []  # initialise
    for producer in producers:
        print(producer)
        producer_price.append(producer.price_list)
    producer_price = np.array(producer_price)
    print(producer_price)
    mean_producer_price = np.nanmean(producer_price, axis=0)


    # get mean consumer price
    consumer_price = []  # initialise
    for consumer in consumers:
        print(consumer)
        consumer_price.append(consumer.price_list)
    consumer_price = np.array(consumer_price)
    mean_consumer_price = np.nanmean(consumer_price, axis=0)

    plt.subplot(2, 1, 1)
    plt.plot(mean_consumer_price, color="r", linestyle="--", linewidth=0.7, label="Mean consumer price")
    plt.plot(mean_producer_price, color="b", linestyle="--", linewidth=0.7, label="Mean producer price")
    plt.ylabel('Price')
    plt.legend()

    plt.subplot(2, 1, 2)
    plt.plot(price_history, color="black", linestyle=":", linewidth=0.7, label="Market price", marker=".", ms=0.7)

    plt.xlabel("Time/days")
    plt.ylabel("Price")
    plt.legend()

    plt.suptitle("Market Price Against Time")
    plt.show()


main()