Skip to content

Static oligopoly equilibria

The equilibria of a Cournot duopoly and of a pricing game of firms with loyal customers and switchers.

  • Name: oligopoly_equilibria
  • Field: economics/markets
  • Formalism: static solution
  • References: Forrest 2018; Brown 1951

Factory

@register_model(
    field='economics/markets',
    tags=('oligopoly', 'equilibrium', 'static', 'game theory'),
    references=('Forrest 2018', 'Brown 1951'),
    observables=_price_observables,
)
def oligopoly_equilibria(
    n_firms: int = 2,
    *,
    alpha: float = 0.25,
    n_prices: int = 21,
    demand: float = 10.0,
    c_i: float = 1.0,
    c_j: float = 2.0,
    lambda_i: float = 0.0,
    lambda_j: float = 0.0,
    dynamics: str = 'static',
) -> System:
    """Return the equilibria of a Cournot duopoly and of a pricing game of firms with loyal customers and switchers.

    The static model solves the Cournot best responses (Forrest 2018 Eqs. 21.5-21.6) and, on a grid of prices, the
    symmetric mixed equilibrium of `m` firms of loyal share `alpha` and switchers `beta = 1 - m alpha`, whose price
    distribution makes a firm indifferent among its prices, at the profit `alpha` (Lemma 20.1). With
    `dynamics='fictitious-play'`, two firms learn to price on the grid instead: every round each plays a best response
    to the prices of its rival so far, and the prices they charge are distributed near the mixed equilibrium.

    Args:
        n_firms:
            The number of firms of the pricing game, two under fictitious play.

        alpha:
            The loyal share of every firm.

        n_prices:
            The number of prices of the grid from `alpha / (alpha + beta)` to 1.

        demand:
            The intercept of the inverse demand of the Cournot duopoly.

        c_i:
            The unit cost of the first Cournot firm.

        c_j:
            The unit cost of the second Cournot firm.

        lambda_i:
            The cross holding of the first Cournot firm.

        lambda_j:
            The cross holding of the second Cournot firm.

        dynamics:
            `'static'` for the equilibria, or `'fictitious-play'` for the learning of two pricing firms.

    Returns:
        system:
            The static model, with the states `cournot.q_i`, `cournot.q_j`, `prices.price`, and `prices.distribution`
            and the observables `lowest_profit` and `highest_profit`, or the learning model, with the group `firms` and
            the observables `lower_price` and `higher_price`. The catalog lists the observables, so a run passes them
            in `observables`.

    Raises:
        ValidationError:
            The dynamics are unknown, or fictitious play has other than two firms.

    Examples:
        >>> from skcomplex.engine.runtime import Simulation
        >>> from skcomplex.models.economics.markets import oligopoly_equilibria
        >>> trajectory = Simulation(oligopoly_equilibria()).check().prepare().run(1.0).trajectory_
        >>> round(float(trajectory.values('cournot.q_i')[0]), 6)
        3.333333
    """
    if dynamics not in DYNAMICS or (dynamics == 'fictitious-play' and n_firms != DUOPOLY):
        error_msg = (
            f'oligopoly_equilibria has dynamics={dynamics!r} and n_firms={n_firms!r}. Use one of {DYNAMICS}, and '
            f'{DUOPOLY} firms under fictitious play.'
        )
        raise ValidationError(error_msg)
    beta = 1 - n_firms * alpha
    prices = np.linspace(alpha / (alpha + beta), 1.0, n_prices)
    if dynamics == 'fictitious-play':
        return _fictitious_play(prices, alpha, beta)
    points = GroupElement(
        'prices',
        element=PricePointElement,
        size=n_prices,
        values={'price': prices, 'alpha': alpha, 'beta': beta, 'n_firms': float(n_firms)},
    )
    cournot = CournotBestResponseElement(
        'cournot',
        demand=demand,
        c_i=c_i,
        c_j=c_j,
        lambda_i=lambda_i,
        lambda_j=lambda_j,
    )
    return System(elements=[cournot, points], time=StaticTime())

Components

class CournotBestResponseElement(Element):
    """Quantities of two firms at the best responses of a Cournot game, where each firm holds a share of the other.

    The conditions are `alpha - 2 q_i - q_j = c_i / (1 + lambda_i)` and its mirror (Forrest 2018 Eq. 21.5), whose
    solution is `q_i = (alpha - 2 c_i / (1 + lambda_i) + c_j / (1 + lambda_j)) / 3` (Eq. 21.6).

    Args:
        label:
            The label of the element.

        q_i:
            The initial guess of the quantity of the first firm, which the equations replace.

        q_j:
            The initial guess of the quantity of the second firm, which the equations replace.

        demand:
            The intercept `alpha` of the inverse demand.

        c_i:
            The unit cost of the first firm.

        c_j:
            The unit cost of the second firm.

        lambda_i:
            The cross holding of the first firm.

        lambda_j:
            The cross holding of the second firm.

    Examples:
        >>> from skcomplex.models.economics.markets import CournotBestResponseElement
        >>> CournotBestResponseElement('duopoly', demand=8.0).demand
        8.0
    """

    def __init__(
        self,
        label: str,
        *,
        q_i: Annotated[float, StateSpec()] = 0.0,
        q_j: Annotated[float, StateSpec()] = 0.0,
        demand: Annotated[float, ParameterSpec()] = 10.0,
        c_i: Annotated[float, ParameterSpec()] = 1.0,
        c_j: Annotated[float, ParameterSpec()] = 2.0,
        lambda_i: Annotated[float, ParameterSpec()] = 0.0,
        lambda_j: Annotated[float, ParameterSpec()] = 0.0,
    ) -> None:
        super().__init__(label)
        self.q_i = q_i
        self.q_j = q_j
        self.demand = demand
        self.c_i = c_i
        self.c_j = c_j
        self.lambda_i = lambda_i
        self.lambda_j = lambda_j

    def _get_laws(self, s: SimpleNamespace) -> Sequence[BasePart]:
        """Return the two conditions of the best responses.

        Args:
            s:
                The namespace of the symbols the laws can use.

        Returns:
            laws:
                The equations of the two quantities.
        """
        return [
            EquationLaw(s.demand - 2 * s.q_i - s.q_j == s.c_i / (1 + s.lambda_i), target=s.q_i),
            EquationLaw(s.demand - 2 * s.q_j - s.q_i == s.c_j / (1 + s.lambda_j), target=s.q_j),
        ]

class PricePointElement(Element):
    """Equilibrium distribution of prices at one price, which makes a firm indifferent among its prices.

    A firm that charges `P` sells to its loyal share `alpha`, and to the switchers `beta` when it undercuts all its
    `m - 1` rivals, each with the probability `1 - F(P)`, for the expected profit `P (alpha + beta (1 - F)^(m - 1))`.
    In equilibrium every price gives the profit `alpha` of selling to the loyal share at the highest price 1, which
    `F(P) = 1 - (alpha (1 - P) / (beta P))^(1 / (m - 1))` solves (Forrest 2018 Lemma 20.1).

    Args:
        label:
            The label of the point or the group.

        distribution:
            The initial guess of `F(P)`, which the equations replace.

        price:
            The price `P`.

        alpha:
            The loyal share of every firm.

        beta:
            The share of switchers.

        n_firms:
            The number of firms `m`.

    Examples:
        >>> from skcomplex.models.economics.markets import PricePointElement
        >>> PricePointElement('point', price=0.5).price
        0.5
    """

    def __init__(
        self,
        label: str,
        *,
        distribution: Annotated[float, StateSpec()] = 0.5,
        price: Annotated[float, StateSpec(domain=RealSet.open_closed(0, 1))] = 1.0,
        alpha: Annotated[float, ParameterSpec(domain=RealSet.open(0, oo))] = 0.25,
        beta: Annotated[float, ParameterSpec(domain=RealSet.open(0, oo))] = 0.5,
        n_firms: Annotated[float, ParameterSpec(domain=RealSet.closed_open(2, oo))] = 2.0,
    ) -> None:
        super().__init__(label)
        self.distribution = distribution
        self.price = price
        self.alpha = alpha
        self.beta = beta
        self.n_firms = n_firms

    @staticmethod
    def get_profit(member: SimpleNamespace) -> Expression:
        """Return the expected profit of a firm at the price of a member, for the observables of a run.

        Args:
            member:
                The namespace of the states and parameters of the member.

        Returns:
            profit:
                The expected profit `P (alpha + beta (1 - F)^(m - 1))`.
        """
        return member.price * (member.alpha + member.beta * (1 - member.distribution) ** (member.n_firms - 1))

    def _get_laws(self, s: SimpleNamespace) -> Sequence[BasePart]:
        """Return the equation of the distribution.

        Args:
            s:
                The namespace of the symbols the laws can use.

        Returns:
            laws:
                The equation of the distribution.
        """
        distribution = 1 - (s.alpha * (1 - s.price) / (s.beta * s.price)) ** (1 / (s.n_firms - 1))
        return [EquationLaw(s.distribution == distribution, target=s.distribution)]