Two markets competing for volume

When one stock is traded on two exchanges, traders tend to send their orders where most of the trading already happens, so a new exchange with a small share of the volume has a hard time taking it from the old one. This tutorial asks what helps the smaller market: a smaller tick size than its rival, or a market maker that always quotes in it. It follows the market-share tutorial of Plham (MarketShareMain and its use cases on the tick size and on the market maker), which is based on the studies listed at the end of this page. The complete script is tutorial_market_share.py. It needs matplotlib for the plots, which is not installed with PAMS (pip install matplotlib).

The model

Two markets, Market-A and Market-B, trade the same stock. The traders are MarketShareFCNAgent agents: FCN agents (Chiarella and Iori, 2002) that can trade in both markets but send each order to only one of them. The market is drawn at random, with a weight equal to the volume executed in that market in the current step and the timeWindowSize steps before it (plus \(10^{-10}\)). A market that trades more therefore receives more orders, and trades even more.

At the start, neither market has traded. The script uses the market class of samples/market_share, which sets the volume of step 0 from the key tradeVolume (see Writing an event or a market for how to write a market):

class ExtendedMarket(Market):
    """A market whose traded volume at step 0 is set by the key ``tradeVolume``."""

    def setup(self, settings: Dict[str, Any], *args: Any, **kwargs: Any) -> None:
        """Set up the market, then set the volume of step 0.

        Args:
            settings (Dict[str, Any]): the market's block of the config. It can
                include "tradeVolume", an int.
            *args: passed to :meth:`pams.Market.setup`.
            **kwargs: passed to :meth:`pams.Market.setup`.

        """
        super().setup(settings, *args, **kwargs)
        if "tradeVolume" in settings:
            if not isinstance(settings["tradeVolume"], int):
                raise ValueError("tradeVolume must be int")
            self._executed_volumes = [int(settings["tradeVolume"])]


Market-A starts with a volume of 90 and Market-B with 10, so at first about 90% of the orders go to Market-A. The volume of step 0 counts only while it is in an agent’s window, that is for its first 100 to 200 steps. After that, a market that has no execution in the window gets almost no orders while the other market trades, and it cannot recover.

The result is the share of Market-B: the volume executed in Market-B divided by the volume executed in both markets, in each window of 100 steps of the second session.

A smaller tick size

The first configuration is samples/market_share/config.json without its comments. Market-A has a tick size of 10 and Market-B a tick size of 1:

CONFIG_TICK: Dict[str, Any] = {
    "simulation": {
        "markets": ["Market-A", "Market-B"],
        "agents": ["MarketShareFCNAgents"],
        "sessions": [
            {
                "sessionName": 0,
                "iterationSteps": 100,
                "withOrderPlacement": True,
                "withOrderExecution": False,
                "withPrint": True,
            },
            {
                "sessionName": 1,
                "iterationSteps": 2000,
                "withOrderPlacement": True,
                "withOrderExecution": True,
                "withPrint": True,
                "maxHighFrequencyOrders": 1,
            },
        ],
    },
    "Market-A": {
        "class": "ExtendedMarket",
        "tickSize": 10.0,
        "marketPrice": 300.0,
        "outstandingShares": 25000,
        "tradeVolume": 90,
    },
    "Market-B": {
        "class": "ExtendedMarket",
        "tickSize": 1.0,
        "marketPrice": 300.0,
        "outstandingShares": 25000,
        "tradeVolume": 10,
    },
    "MarketShareFCNAgents": {
        "class": "MarketShareFCNAgent",
        "numAgents": 100,
        "markets": ["Market-A", "Market-B"],
        "assetVolume": 50,
        "cashAmount": 10000,
        "fundamentalWeight": {"expon": [1.0]},
        "chartWeight": {"expon": [0.2]},
        "noiseWeight": {"expon": [1.0]},
        "noiseScale": 0.0001,
        "timeWindowSize": [100, 200],
        "orderMargin": [0.0, 0.1],
        "marginType": "normal",
    },
}

The sample writes maxHifreqOrders, the old name of maxHighFrequencyOrders (see Sessions), and the script uses the new name. The key has no effect here, as there is no high-frequency agent. The script registers the market class, runs the simulation and computes the share of Market-B:

def run_simulation(
    config: Dict[str, Any], seed: int, main_steps: Optional[int] = None
) -> SequentialRunner:
    """Run a simulation with the market class of this tutorial.

    Args:
        config (Dict[str, Any]): the configuration. It is not changed.
        seed (int): seed of the random number generator.
        main_steps (int, Optional): number of steps of the main session. If None,
            the number of the configuration is used.

    Returns:
        SequentialRunner: the runner after the run.

    """
    config = copy.deepcopy(config)
    if main_steps is not None:
        config["simulation"]["sessions"][1]["iterationSteps"] = main_steps
    runner = SequentialRunner(settings=config, prng=random.Random(seed))
    runner.class_register(cls=ExtendedMarket)
    # hide the two time lines printed by main()
    with contextlib.redirect_stdout(io.StringIO()):
        runner.main()
    return runner


def share_of_b(runner: SequentialRunner, window: int = 100) -> List[float]:
    """Compute the share of Market-B in the volume of each window of the main session.

    Args:
        runner (SequentialRunner): the runner after the run.
        window (int): the number of steps of a window.

    Returns:
        List[float]: the volume traded in Market-B divided by the volume traded in
        both markets, for each window. It is 0 when nothing is traded.

    """
    market_a: Market = runner.simulator.name2market["Market-A"]
    market_b: Market = runner.simulator.name2market["Market-B"]
    session = runner.simulator.sessions[1]
    start: int = session.session_start_time
    end: int = start + session.iteration_steps
    shares: List[float] = []
    for time in range(start, end, window):
        steps = range(time, min(time + window, end))
        volume_a: int = sum(market_a.get_executed_volumes(times=steps))
        volume_b: int = sum(market_b.get_executed_volumes(times=steps))
        shares.append(volume_b / max(volume_a + volume_b, 1))
    return shares


The market rounds the price of each order to its tick size, down for a buy order and up for a sell order. With a tick size of 10, an agent that would buy at 305 bids 300, and one that would sell at 303 asks 310, so the orders in Market-A cross less often than in Market-B. Running the script first prints the share of Market-B with seed 42, with a tick size of 10 and of 2 in Market-A:

steps        tick A 10   tick A 2
100-199           0.06       0.05
200-299           0.06       0.05
300-399           0.04       0.09
400-499           0.05       0.07
500-599           0.10       0.02
600-699           0.45       0.00
700-799           0.74       0.00
800-899           0.96       0.00
900-999           1.00       0.00
...
2000-2099         1.00       0.00
Top: prices of the two markets with a tick size of 10 in Market-A. The price of Market-A is 300, then 310 from step 470, and then stops changing, while the price of Market-B moves around the fundamental price of 300. Bottom: share of Market-B in each window of 100 steps. With a tick size of 10 it rises from about 0.05 to 1 between steps 500 and 1000. With a tick size of 2 it falls to 0 at step 700.

With a tick size of 10, Market-B keeps about 5% of the volume until step 499, then takes all of it: after step 834, Market-A has no trade, and its price stays at 310, one of the only two prices at which it traded (300 and 310). With a tick size of 2 and the same seed, the opposite happens: Market-B has no trade after step 510.

One seed shows one path. The script repeats the runs with seeds 42 to 51, with a tick size of 10, 5 and 2 in Market-A and a second session of 1000 steps:

def compare(
    configs: Dict[str, Dict[str, Any]], seeds: Sequence[int], main_steps: int
) -> Dict[str, List[List[float]]]:
    """Run each configuration with each seed.

    Args:
        configs (Dict[str, Dict[str, Any]]): the configurations, by name.
        seeds (Sequence[int]): the seeds.
        main_steps (int): number of steps of the main session.

    Returns:
        Dict[str, List[List[float]]]: for each configuration, and for each seed, the
        share of Market-B in each window of 100 steps.

    """
    return {
        name: [share_of_b(run_simulation(config, seed, main_steps)) for seed in seeds]
        for name, config in configs.items()
    }


It then prints, for each tick size, the share of Market-B in the last window (steps 1000 to 1099) of each seed, their mean, and the number of seeds in which Market-B has more than half of the volume:

tick A  B wins  mean share  share of B by seed
    10   3/10         0.27  1.00 0.00 0.00 1.00 0.00 0.00 0.00 0.53 0.03 0.15
     5   1/10         0.11  0.00 0.00 0.25 0.06 0.00 0.00 0.00 0.12 0.02 0.64
     2   0/10         0.03  0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.12 0.02 0.17

The larger the tick size of Market-A, the more often Market-B takes the volume: in 3 of 10 runs with a tick size of 10, in 1 with 5 and in none with 2. But the smaller tick size is not enough to win: in most runs, Market-A keeps almost all the volume, because the orders follow the volume and Market-A starts with 90% of it. A single run can also go against the average: with seed 51, Market-B has 0.64 of the volume with a tick size of 5 but only 0.15 with 10, since a different tick size changes the whole path of the run.

A market maker

The second configuration is samples/market_share/config-mm.json. Both markets have a tick size of 0.00001, so the tick size plays no role, and a MarketMakerAgent trades in Market-B only:

CONFIG_MM: Dict[str, Any] = {
    "simulation": {
        "markets": ["Market-A", "Market-B"],
        "agents": ["MarketShareFCNAgents", "MarketMakerAgent"],
        "sessions": [
            {
                "sessionName": 0,
                "iterationSteps": 100,
                "withOrderPlacement": True,
                "withOrderExecution": False,
                "withPrint": True,
            },
            {
                "sessionName": 1,
                "iterationSteps": 2000,
                "withOrderPlacement": True,
                "withOrderExecution": True,
                "withPrint": True,
                "maxHighFrequencyOrders": 1,
            },
        ],
    },
    "Market-A": {
        "class": "ExtendedMarket",
        "tickSize": 0.00001,
        "marketPrice": 300.0,
        "outstandingShares": 25000,
        "tradeVolume": 90,
    },
    "Market-B": {
        "class": "ExtendedMarket",
        "tickSize": 0.00001,
        "marketPrice": 300.0,
        "outstandingShares": 25000,
        "tradeVolume": 10,
    },
    "MarketShareFCNAgents": {
        "class": "MarketShareFCNAgent",
        "numAgents": 100,
        "markets": ["Market-A", "Market-B"],
        "assetVolume": 50,
        "cashAmount": 10000,
        "fundamentalWeight": {"expon": [1.0]},
        "chartWeight": {"expon": [0.0]},
        "noiseWeight": {"expon": [1.0]},
        "noiseScale": 0.001,
        "timeWindowSize": [100, 200],
        "orderMargin": [0.0, 0.1],
    },
    "MarketMakerAgent": {
        "class": "MarketMakerAgent",
        "numAgents": 1,
        "markets": ["Market-B"],
        "assetVolume": 50,
        "cashAmount": 10000,
        "targetMarket": "Market-B",
        "netInterestSpread": 0.02,
        "orderTimeLength": 2,
    },
}

The market maker is a high-frequency agent: after the order of the normal agent, in every step of both sessions (maxHighFrequencyOrders is 1, and so is its default), it places a buy order and a sell order of one share around the middle \(c\) of the best buy and sell prices of Market-B, its only tradable market. With the spread \(s\) (netInterestSpread) and the fundamental price \(P^f = 300\), it buys at \(c - P^f s / 2\) and sells at \(c + P^f s / 2\). Its orders expire after orderTimeLength = 2 steps. The details are in Agents.

The script runs seeds 42 to 51 without the market maker and with a spread of 0.02, 0.01 and 0.0001, with a second session of 500 steps. It changes the configuration with this function:

def with_spread(spread: Optional[float]) -> Dict[str, Any]:
    """Make a copy of ``CONFIG_MM`` with another spread of the market maker.

    Args:
        spread (float, Optional): the spread of the market maker. If None, the
            market maker is removed.

    Returns:
        Dict[str, Any]: the new configuration.

    """
    config: Dict[str, Any] = copy.deepcopy(CONFIG_MM)
    if spread is None:
        config["simulation"]["agents"].remove("MarketMakerAgent")
    else:
        config["MarketMakerAgent"]["netInterestSpread"] = spread
    return config

It prints the share of Market-B in the last window (steps 500 to 599):

spread  B wins  mean share  share of B by seed
  none   0/10         0.10  0.00 0.08 0.21 0.31 0.00 0.00 0.00 0.08 0.23 0.06
  0.02   2/10         0.41  0.00 0.31 0.40 0.53 0.39 0.29 0.42 0.49 0.43 0.87
  0.01   6/10         0.56  0.00 0.29 0.70 0.55 0.80 0.41 0.50 0.82 0.51 1.00
0.0001   8/10         0.65  0.00 0.32 0.86 0.75 0.80 0.57 0.83 0.85 0.54 1.00
Share of Market-B in each window of 100 steps, averaged over the 10 seeds. Without the market maker it stays near 0.1. With the market maker it rises in every case, to about 0.41 with a spread of 0.02, 0.56 with 0.01 and 0.65 with 0.0001.

Without the market maker, Market-B keeps about its initial 10% on average and wins in no run. With the market maker, its share rises in every run but one, and the more so the smaller the spread: the market maker’s orders are close to the middle price, so an order sent to Market-B is more likely to find a counterpart and be executed, and the volume draws more orders. Note that the share counts all the volume of Market-B, including the trades of the market maker.

Seed 42 is the exception: with every spread, Market-B has no trade at all in the second session. The few orders sent to it early do not meet the market maker’s orders, its weight falls to almost zero when the volume of step 0 leaves the windows, and no agent chooses it again.

Differences from Plham

  • Plham writes the volume of each step and computes the share of Market-B over the past 100 steps with an R script. Here, the script computes it in Python, in consecutive windows of 100 steps.

  • The tick size use case of Plham uses a tick size of 5 in Market-A (config-01.json) and shows one run in which Market-B takes all the volume around step 1000, and a slower rise with a tick size of 2. The PAMS sample uses 10. Over ten seeds, PAMS finds the same direction, but Market-B wins only in 1 of 10 runs with a tick size of 5.

  • The market maker use case of Plham shows the prices of one run with a spread of 0.02, and the share of Market-B in one run each with 0.01 and 0.0001, in which the smaller spread lets Market-B take the volume faster. PAMS finds the same order over ten seeds, with one seed (42) in which Market-B does not gain.

  • The agents count the volume of a market differently when they choose where to send an order. MarketShareFCNAgent sums the executed volume of the steps \(t - \tau\) to \(t\), the current step included, and adds \(10^{-10}\) to each weight. The agent of the Plham sample sums the steps \(t - \tau\) to \(t - 1\) and adds nothing. Here \(t\) is the current step and \(\tau\) is timeWindowSize, or \(t\) when it is smaller.

References

  • Chiarella, C. and Iori, G. (2002). A simulation analysis of the microstructure of double auction markets. Quantitative Finance, 2(5), 346–353. https://doi.org/10.1088/1469-7688/2/5/303

  • 水田孝信, 早川聡, 和泉潔, 吉村忍 (Mizuta, Hayakawa, Izumi and Yoshimura) (2013). 人工市場シミュレーションを用いた取引市場間におけるティックサイズと取引量の関係性分析. JPXワーキング・ペーパー, Vol. 2, 日本取引所グループ.

  • 草田裕紀, 水田孝信, 早川聡, 和泉潔, 吉村忍 (Kusada, Mizuta, Hayakawa, Izumi and Yoshimura) (2014). 人工市場を用いたマーケットメーカーのスプレッドが市場出来高に与える影響の分析. 人工知能学会全国大会論文集, JSAI2014, 3L3-OS-26a-2. https://doi.org/10.11517/pjsai.JSAI2014.0_3L3OS26a2

  • 草田裕紀, 水田孝信, 早川聡, 和泉潔 (Kusada, Mizuta, Hayakawa and Izumi) (2015). 保有資産を考慮したマーケットメイク戦略が取引所間競争に与える影響:人工市場アプローチによる分析. 人工知能学会論文誌, 30(5), 675–682. https://doi.org/10.1527/tjsai.30_675

  • Plham: MarketShareMain, MarketShareMain_UseCases01 and MarketShareMain_UseCases02.