A fat-finger order

A fat-finger error is an order typed by mistake, with a wrong price or a much larger volume than intended. This tutorial places one very large sell order below the market price in the simulation of Running a simulation, records the order book around it, and asks how long the market takes to recover and how the answer depends on the size of the mistake. It follows the fat-finger tutorial of Plham (FatFingerMain and its use cases). The complete script is tutorial_fat_finger.py. It needs matplotlib for the plots, which is not installed with PAMS (pip install matplotlib).

The model

The market and the 100 FCNAgent agents are those of Running a simulation. Each agent expects a price from a fundamental term and a noise term (the FCN model of Chiarella and Iori, 2002, with the chart weight set to 0), and in each step one of them places a limit order of one share around its expected price. The fundamental price stays at 300.

The mistake is the built-in OrderMistakeShock event. At its trigger step, it takes the first order that reaches the target market and replaces it with a limit order of orderVolume shares at \(P (1 + r)\), where \(P\) is the current market price and \(r\) is priceChangeRate. A negative \(r\) gives a sell order, a positive one a buy order. The agent that sent the original order owns the mistaken one, and the order stays in the book for orderTimeLength steps unless it is executed.

The configuration

The configuration is that of samples/fat_finger without its comments. The second session ("sessionName": 1) lists the event, and the event block sets a sell order of 10000 shares at 5% below the market price, with a lifetime of 10000 steps:

CONFIG: Dict[str, Any] = {
    "simulation": {
        "markets": ["Market"],
        "agents": ["FCNAgents"],
        "sessions": [
            {
                "sessionName": 0,
                "iterationSteps": 100,
                "withOrderPlacement": True,
                "withOrderExecution": False,
                "withPrint": True,
            },
            {
                "sessionName": 1,
                "iterationSteps": 500,
                "withOrderPlacement": True,
                "withOrderExecution": True,
                "withPrint": True,
                "events": ["OrderMistakeShock"],
            },
        ],
    },
    "OrderMistakeShock": {
        "class": "OrderMistakeShock",
        "target": "Market",
        "triggerTime": 100,
        "priceChangeRate": -0.05,
        "orderVolume": 10000,
        "orderTimeLength": 10000,
        "enabled": True,
    },
    "Market": {
        "class": "Market",
        "tickSize": 0.00001,
        "marketPrice": 300.0,
        "outstandingShares": 25000,
    },
    "FCNAgents": {
        "class": "FCNAgent",
        "numAgents": 100,
        "markets": ["Market"],
        "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],
    },
}

triggerTime is counted from the start of the session, which is step 100, so the mistake happens at step 200. All the keys are described in Events. Apart from the event, this is the configuration of Running a simulation with other session names and an outstandingShares key, which do not change the run. The script also runs it without the mistake by setting enabled to False: the event then registers no hooks and is not added to the simulator, and the market prices are exactly those of Running a simulation with the same seed.

Recording the order book

The default logger does not keep the order book, so the script uses its own logger (see Writing a logger). It keeps every order and every execution, and the volume at each price of both sides of the book at the end of each step:

class BookRecorder(Logger):
    """Keep every order and execution, and the order book at the end of every step."""

    def __init__(self) -> None:
        """Initialize the recorder."""
        super().__init__()
        self.orders: List[OrderLog] = []
        self.executions: List[ExecutionLog] = []
        self.buy_books: Dict[int, Dict[Optional[float], int]] = {}
        self.sell_books: Dict[int, Dict[Optional[float], int]] = {}

    def process_order_log(self, log: OrderLog) -> None:
        """Keep an order accepted by the market.

        Args:
            log (OrderLog): the order, after the market has rounded its price.

        """
        self.orders.append(log)

    def process_execution_log(self, log: ExecutionLog) -> None:
        """Keep an execution.

        Args:
            log (ExecutionLog): the execution.

        """
        self.executions.append(log)

    def process_market_step_end_log(self, log: MarketStepEndLog) -> None:
        """Keep the volume at each price of the order book at the end of the step.

        Args:
            log (MarketStepEndLog): the log of the market at the end of the step.

        """
        time: int = log.market.get_time()
        self.buy_books[time] = log.market.get_buy_order_book()
        self.sell_books[time] = log.market.get_sell_order_book()


The market rounds the price of an order to its tick size before it logs the order, so the prices in the order logs are those of the book. The books are dicts from a price to the volume of the orders at that price; the key None would collect market orders, which the FCN agents do not place.

Two small functions find the mistaken order and count the part of it that is still in the book:

def find_mistake(runner: SequentialRunner, recorder: BookRecorder) -> OrderLog:
    """Find the mistaken order among the orders of the run.

    Args:
        runner (SequentialRunner): the runner after the run.
        recorder (BookRecorder): the recorder of the run.

    Returns:
        OrderLog: the order placed at the trigger step. Only one order is placed per
        step, so this is the order that the event replaced.

    """
    event = cast(OrderMistakeShock, runner.simulator.name2event["OrderMistakeShock"])
    return next(order for order in recorder.orders if order.time == event.trigger_time)


def wall_left(recorder: BookRecorder, mistake: OrderLog, time: int) -> int:
    """Count the shares of the mistaken order not executed at the end of a step.

    Args:
        recorder (BookRecorder): the recorder of the run.
        mistake (OrderLog): the mistaken order.
        time (int): the step.

    Returns:
        int: the volume of the mistaken order minus its executions up to ``time``.

    """
    executed: int = sum(
        execution.volume
        for execution in recorder.executions
        if execution.sell_order_id == mistake.order_id and execution.time <= time
    )
    return mistake.volume - executed


Only one order is placed per step (maxNormalOrders is 1), so the order logged at the trigger step is the mistaken one. Running the script with seed 42 first prints the mistaken order, then the market price, the best prices, the volume on each side of the book and what is left of the mistaken order at the end of a few steps:

mistaken order: FCNAgents-28 sells 10000 at 279.95251 in step 200
step    price  best buy  best sell  buy volume  sell volume  wall left
 199  294.687   290.590    293.116          28           39          -
 200  279.963   277.361    279.953          10        10021       9982
 201  279.953   277.361    279.953          10        10020       9981
 250  279.953   275.791    279.953          16         9984       9953
 400  274.649   274.508    279.953          27         9892       9874
 599  279.953   274.561    279.953          26         9803       9793

The market price at step 199 is 294.687, so the mistaken order sells at \(294.687 \times 0.95 = 279.953\). At step 200 it meets every buy order at or above that price: 18 shares are executed, and the buy side of the book falls from 28 to 10 shares. The best buy price drops from 290.590 to 277.361. All 18 shares trade at one price, 279.963: PAMS executes all the volume matched in one step at a single price, set by the last pair of orders matched, which here is the lowest of these buy orders.

The other 9982 shares stay in the book as a wall. From then on, any buy order at or above 279.953 is executed against the wall at the wall’s price, 279.953, so the market price can no longer rise above it; it only falls below it when a sell order meets a lower buy order, as at step 400. By step 599, the buyers have taken 207 of the 10000 shares.

The price after the mistake

The script also runs the same seed without the mistake, and with a mistaken order of only 100 shares, and compares the market prices of steps 201 to 599 and the volume traded in them:

steps 201-599        min      max     last  volume
no mistake         289.481  314.085  297.401     149
mistake, 10000     272.227  279.953  279.953     245
mistake, 100       275.821  313.867  297.396     192
Market prices of the three runs from step 100 to step 599. All three follow the same path until step 200. The run with 10000 shares then stays at or below the dashed line of the mistaken order near 280, the run with 100 shares climbs back to about 300 after step 350, and the run without the mistake stays around the fundamental price of 300.

With 10000 shares, the price never exceeds the price of the mistaken order from step 201 on, and ends the run 7% below the fundamental price. The FCN agents expect the price to return to 300 and send many buy orders, which is why more shares are traded than without the mistake, but one share at a time they cannot absorb the wall: at about one share every two steps, the 9793 shares left would last about 19000 more steps, longer than the order’s lifetime. With 100 shares, the wall is used up at step 347 and the price returns to the level of the run without the mistake.

How large must the mistake be?

One run is one path. The script repeats the runs with seeds 42 to 51, with mistaken orders of 10000, 100 and 10 shares and without the mistake, and records the step at which the mistaken order is used up and the mean market price of steps 500 to 599:

def compare_volumes(
    volumes: Sequence[Optional[int]] = (10000, 100, 10, None),
    seeds: Sequence[int] = SEEDS,
) -> Dict[str, List[Tuple[Optional[int], float]]]:
    """Run the simulation with mistaken orders of several volumes, and without one.

    Args:
        volumes (Sequence[int, Optional]): the volumes of the mistaken order. None
            runs the simulation without the mistaken order.
        seeds (Sequence[int]): the seeds of the runs.

    Returns:
        Dict[str, List[Tuple[int, Optional], float]]: for each volume, and for each
        seed, the step at which the mistaken order is fully executed (None if it is
        not) and the mean market price of steps 500 to 599.

    """
    results: Dict[str, List[Tuple[Optional[int], float]]] = {}
    for volume in volumes:
        rows: List[Tuple[Optional[int], float]] = []
        for seed in seeds:
            runner, recorder = run_simulation(
                seed, enabled=volume is not None, volume=volume
            )
            used_up: Optional[int] = None
            if volume is not None:
                mistake: OrderLog = find_mistake(runner, recorder)
                used_up = next(
                    (
                        time
                        for time in range(mistake.time, 600)
                        if wall_left(recorder, mistake, time) == 0
                    ),
                    None,
                )
            market = runner.simulator.name2market["Market"]
            mean_price: float = statistics.mean(
                market.get_market_prices(times=range(500, 600))
            )
            rows.append((used_up, mean_price))
        results["none" if volume is None else str(volume)] = rows
    return results


volume  used up  mean step  mean price 500-599
 10000     0/10          -              281.81
   100    10/10      437.7              294.94
    10    10/10      205.9              298.55
  none        -          -              298.43

The 10000-share order is never used up, and the mean price of steps 500 to 599 is about 6% below the fundamental price. An order of 100 shares is used up in every run, on average about 240 steps after the mistake, and the mean price of steps 500 to 599 is only 3.5 lower than without the mistake. An order of 10 shares is used up about 6 steps after the mistake on average, and leaves no trace in the late prices. The size of the mistake, compared with the flow of buy orders that can absorb it, decides whether the market recovers within the run.

The order book

The recorded books show the wall directly. Each dot is a price with at least one order at the end of a step:

Prices of the buy and sell orders in the book from step 150 to step 350. Until step 200 the buy orders lie between about 270 and 303. At step 200 the buy orders above 280 disappear, and a line of sell orders at 280 appears and stays until step 350, with all later buy orders below it.

Before step 200, buy orders sit up to about 303. At step 200, those above 279.953 disappear, and the mistaken order forms a line of sell orders at 279.953 that stays for the rest of the run. The sell orders placed above it are never reached, and new buy orders pile up below it.

Differences from Plham

  • In Plham, the configuration names an agent group ("agent"), and the first agent of that group places the mistaken order in addition to the orders of the step. In PAMS, the agent key is ignored with a warning, and the event replaces the first order that reaches the market at the trigger step, so the owner is whichever agent placed that order (here FCNAgents-28).

  • In the run shown by Plham, the market price falls to about 285 at the mistake. Here it falls to 279.953, 5% below the market price of step 199, which was already below the fundamental price.

  • PAMS does not check that an agent owns the shares it sells: FCNAgents-28 started with 50 shares and ends the run holding -155.

  • Plham writes the whole order book in its output (OrderBook#dump) and plots it with an R script. Here, a logger keeps the volume at each price of the book, and matplotlib draws it.

References