Agent-based modeling and simulation in US equity markets – Part 3

This is the final post of an introduction to agent-based models in US equity markets. The first post provided a definition of a model and a brief overview of how economists use models to simplify reality. The second post introduced agent-based models, simulation, and how they are related. We will conclude with an introduction to the polar types of agents: zero-intelligence and learning agents.

Zero intelligence versus learning agents

Gode and Sunder (1993) define a zero intelligence (ZI) trader as a trader that “has no intelligence, does not seek or maximize profits, and does not observe, remember, or learn.” Farmer et al. (2005) also provide a description: “The model makes the simple assumption that agents place orders to buy or sell at random, subject to constraints imposed by current prices.” They go on to explain that their ZI traders do observe current prices – a deviation from the previous definition. The constraints are essentially dynamic bounds on limit order prices.

The agents in my Tick Pilot paper are ZI as characterized in Farmer et al. (2005): they observe the top of book (price and size) and place orders to buy or sell consistent with their pricing heuristic. The buying and selling choice is random subject to pricing constraints.

Holland et al. (1986) provide a description of learning agents. This description is summarized in Beinhocker (2006):

  1. Agents interact with other agents and the environment.
  2. The agent has a goal or set of goals and can perceive the gap between its current state and desired state.
  3. The agent has a set of heuristics (rules of thumb) that map the current state into decisions. This is called the agent’s mental model.
  4. The agent’s mental model tracks which rules have helped it achieve its goals. Historically successful rules are used more often than less successful rules. Feedback from the environment causes the agent to learn over time.

Holland and Miller (1991) define complex (1, 2, 3) adaptive (4, 5) systems:

  1. A network of interacting agents.
  2. Exhibits dynamic behavior that emerges from the individual agent activities.
  3. Aggregate behavior can be described without detailed knowledge of the individual agents.
  4. Agent actions can be assigned a value (payoff, fitness, utility).
  5. Agent behaves so as to increase this value over time.

Beinhocker (2006) summarizes the evolutionary (learning) approach: differentiate, select, amplify. The evolutionary approach is implemented on a computer with genetic algorithms.

Genetic algorithm

Wikipedia gets the final word: In computer science and operations research, a genetic algorithm (GA) is a metaheuristic inspired by the process of natural selection that belongs to the larger class of evolutionary algorithms (EA). Genetic algorithms are commonly used to generate high-quality solutions to optimization and search problems by relying on bio-inspired operators such as mutation, crossover and selection.

There’s a lot going on in this definition. The links are helpful – especially to folks with a hard science background. But, I suspect it is all a bit of a mystery to those who never studied evolutionary biology, computer science, physics, etc. How would you explain zero intelligence, learning agents and genetic algorithms to an accountant, attorney or MBA?

In an upcoming post, I will provide some specific examples of failed attempts to communicate with a wider audience about simulation and agent-based modeling applied to US equity markets.

Unit testing the zero-intelligence traders with Python

In this post I will walk through unit testing the zero-intelligence trader classes and methods discussed in Coding some zero-intelligence traders with Python. Again, I will use the unittest module from the Python Standard Library. See Unit testing a simple limit order book with Python for a brief discussion of the unit testing choices available to you in the Python biosphere.

The full code and directory structure is available in my GitHub repo. You might find it helpful to have the actual Trader code handy when walking through the tests. As usual, we will create a separate test module, called testTrader2017_r3.py, and import the necessary support modules.

import random
import unittest

import numpy as np

from pyziabm.trader2017_r3 import Provider, Provider5, Taker, MarketMaker, MarketMaker5, PennyJumper

Next we declare the test class and define the setUp() fixture. In setUp() we instantiate the traders and some quotes for use with the tests to follow.

class TestTrader(unittest.TestCase):
    '''
    Five classes:
    1. ZITrader is a base class with make_add_quote()
    2. Taker inherits from ZITrader with process_signal()
    3. Provider inherits from ZITrader with make_cancel_quote(), confirm_cancel_local(), confirm_trade_local(), 
       process_signal(), choose_price_from_exp() and bulk_cancel().
    4. MarketMaker inherits from Provider with confirm_trade_local(), cumulate_cashflow() and process_signal().
    5. PennyJumper inherits from ZITrader with make_cancel_quote(), confirm_trade_local() and process_signal().
       PennyJumper has an ask_quote dictionary and bid_quote dictionary which can be None.
    '''

    def setUp(self):
        self.p1 = Provider('p1', 1, 1, 0.05)
        self.p5 = Provider5('p5', 1, 5, 0.05)
        self.t1 = Taker('t1', 1)
        self.m1 = MarketMaker('m1', 1, 1, 0.05, 12, 60)
        self.m5 = MarketMaker5('m5', 1, 5, 0.05, 12, 60)
        self.j1 = PennyJumper('j1', 1, 5)
        self.q1 = {'order_id': 'p1_1', 'timestamp': 1, 'type': 'add', 'quantity': 1, 'side': 'buy',
                   'price': 125}
        self.q2 = {'order_id': 'p1_2', 'timestamp': 2, 'type': 'add', 'quantity': 5, 'side': 'buy',
                   'price': 125}
        self.q3 = {'order_id': 'p1_3', 'timestamp': 3, 'type': 'add', 'quantity': 1, 'side': 'buy',
                   'price': 124}
        self.q4 = {'order_id': 'p1_4', 'timestamp': 4, 'type': 'add', 'quantity': 1, 'side': 'buy',
                   'price': 123}
        self.q5 = {'order_id': 'p1_5', 'timestamp': 5, 'type': 'add', 'quantity': 1, 'side': 'buy',
                   'price': 122}
        self.q6 = {'order_id': 'p1_6', 'timestamp': 6, 'type': 'add', 'quantity': 1, 'side': 'sell',
                   'price': 126}
        self.q7 = {'order_id': 'p1_7', 'timestamp': 7, 'type': 'add', 'quantity': 5, 'side': 'sell',
                   'price': 127}
        self.q8 = {'order_id': 'p1_8', 'timestamp': 8, 'type': 'add', 'quantity': 1, 'side': 'sell',
                   'price': 128}
        self.q9 = {'order_id': 'p1_9', 'timestamp': 9, 'type': 'add', 'quantity': 1, 'side': 'sell',
                   'price': 129}
        self.q10 = {'order_id': 'p1_10', 'timestamp': 10, 'type': 'add', 'quantity': 1, 'side': 'sell',
                    'price': 130}

ZITrader is a base class with one responsibility: make the quote. This quote is the only required means of communication from the trader to the order book. All traders ultimately inherit the _make_add_quote() method from ZITrader. Though the test is very simple, I include it to remind myself if/when I make changes to the quote structure. The testing strategy for the trader instances establishes the state of the trader before calling the specific method (if necessary), creates any needed inputs, calls the method, and finally, tests that the output matches what is expected.

    def test_make_add_quote(self):
        time = 1
        quantity = 1
        side = 'sell'
        price = 125
        q = self.p1._make_add_quote(time, quantity, side, price)
        expected = {'order_id': 'p1_1', 'timestamp': 1, 'type': 'add', 'quantity': 1, 'side': 'sell', 
                    'price': 125}
        self.assertDictEqual(q, expected)

The Taker class inherits directly from ZITrader. It overrides the __repr__() method to include the trader_type attribute in the test. This public attribute is used in the simulation loop to determine which type of trader (in this case, a taker) is currently active. The Taker class also adds a process_signal() method, which is tested with some random seeds to ensure we get a buy order and a sell order.

    def test_repr_Taker(self):
        #print('Provider: {0}, Taker: {1}'.format(self.p1, self.t1))
        self.assertEqual('Taker: Trader(t1, 1, Taker)', 'Taker: {0}'.format(self.t1))
        
    def test_process_signal_Taker(self):
        '''
        Generates a quote object (dict) and appends to quote_collector
        '''
        time = 1
        q_taker = 0.5
        low_ru_seed = 1
        hi_ru_seed = 10
        self.assertFalse(self.t1.quote_collector)
        random.seed(low_ru_seed)
        self.t1.process_signal(time, q_taker)
        self.assertEqual(len(self.t1.quote_collector), 1)
        self.assertEqual(self.t1.quote_collector[0]['side'], 'buy')
        self.assertEqual(self.t1.quote_collector[0]['price'], 2000000)
        random.seed(hi_ru_seed)
        self.t1.process_signal(time, q_taker)
        self.assertEqual(len(self.t1.quote_collector), 1)
        self.assertEqual(self.t1.quote_collector[0]['side'], 'sell')
        self.assertEqual(self.t1.quote_collector[0]['price'], 0)

The Provider class also inherits directly from ZITrader and overrides the __repr__() method to include the trader_type attribute.

    def test_repr_Provider(self):
        #print('Provider: {0}, Taker: {1}'.format(self.p1, self.t1))
        self.assertEqual('Provider: Trader(p1, 1, Provider)', 'Provider: {0}'.format(self.p1))
        self.assertEqual('Provider: Trader(p5, 1, Provider)', 'Provider: {0}'.format(self.p5))

The remaining tests address three activities: confirming messages from the order book, canceling outstanding orders and generating add orders. The confirm tests both add some orders to the trader’s local book, call the method, and then test for the expected impact on the local book.

    def test_confirm_cancel_local_Provider(self):
        self.p1.local_book[self.q1['order_id']] = self.q1
        self.p1.local_book[self.q2['order_id']] = self.q2
        self.assertEqual(len(self.p1.local_book), 2)
        q = self.p1._make_cancel_quote(self.q1, 2)
        self.p1.confirm_cancel_local(q)
        self.assertEqual(len(self.p1.local_book), 1)
        expected = {self.q2['order_id']: self.q2}
        self.assertDictEqual(self.p1.local_book, expected)

    def test_confirm_trade_local_Provider(self):
        '''
        Test Provider for full and partial trade
        '''
        # Provider
        self.p1.local_book[self.q1['order_id']] = self.q1
        self.p1.local_book[self.q2['order_id']] = self.q2
        # trade full quantity of q1
        trade1 = {'timestamp': 2, 'trader': 'p1', 'order_id': 'p1_1', 'quantity': 1, 'side': 'buy', 'price': 2000000}
        self.assertEqual(len(self.p1.local_book), 2)
        self.p1.confirm_trade_local(trade1)
        self.assertEqual(len(self.p1.local_book), 1)
        expected = {self.q2['order_id']: self.q2}
        self.assertDictEqual(self.p1.local_book, expected)
        # trade partial quantity of q2
        trade2 = {'timestamp': 3, 'trader': 'p1', 'order_id': 'p1_2', 'quantity': 2, 'side': 'buy', 'price': 2000000}
        self.p1.confirm_trade_local(trade2)
        self.assertEqual(len(self.p1.local_book), 1)
        expected = {'order_id': 'p1_2', 'timestamp': 2, 'type': 'add', 'quantity': 3, 'side': 'buy', 
                    'price': 125}
        self.assertDictEqual(self.p1.local_book.get(trade2['order_id']), expected)

The cancel tests include a simple test for generating a cancel order and some tests for randomly canceling orders on the local book. The inline comments provide more details.

    def test_make_cancel_quote_Provider(self):
        q = self.p1._make_cancel_quote(self.q1, 2)
        expected = {'order_id': 'p1_1', 'timestamp': 2, 'type': 'cancel', 'quantity': 1, 'side': 'buy', 
                    'price': 125}
        self.assertDictEqual(q, expected)

    def test_bulk_cancel_Provider(self):
        '''
        Put 10 orders in the book, use random seed to determine which orders are cancelled,
        test for cancelled orders in the queue
        '''
        self.assertFalse(self.p1.local_book)
        self.assertFalse(self.p1.cancel_collector)
        self.p1.local_book[self.q1['order_id']] = self.q1
        self.p1.local_book[self.q2['order_id']] = self.q2
        self.p1.local_book[self.q3['order_id']] = self.q3
        self.p1.local_book[self.q4['order_id']] = self.q4
        self.p1.local_book[self.q5['order_id']] = self.q5
        self.p1.local_book[self.q6['order_id']] = self.q6
        self.p1.local_book[self.q7['order_id']] = self.q7
        self.p1.local_book[self.q8['order_id']] = self.q8
        self.p1.local_book[self.q9['order_id']] = self.q9
        self.p1.local_book[self.q10['order_id']] = self.q10
        self.assertEqual(len(self.p1.local_book), 10)
        self.assertFalse(self.p1.cancel_collector)
        # np.random seed = 8 generates 1 position less than 0.025 from np.random.ranf: 5
        np.random.seed(8)
        self.p1._delta = 0.025
        self.p1.bulk_cancel(11)
        self.assertEqual(len(self.p1.cancel_collector), 1)
        # np.random seed = 7 generates 2 positions less than 0.1 from np.random.ranf: 0, 7
        np.random.seed(7)
        self.p1._delta = 0.1
        self.p1.bulk_cancel(12)
        self.assertEqual(len(self.p1.cancel_collector), 2)
        # np.random seed = 6 generates 0 position less than 0.025 from np.random.ranf
        np.random.seed(6)
        self.p1._delta = 0.025
        self.p1.bulk_cancel(12)
        self.assertFalse(self.p1.cancel_collector)

The final Provider tests check the methods for creating a new add quote. test_choose_price_from_exp() tests for choosing a price consistent with the proper side of the book and for the correct pricing increment, which in this case is either 1 or 5. test_process_signal() checks for the random choice of buy or sell.

    def test_choose_price_from_exp(self):
        # mpi == 1
        sell_price = self.p1._choose_price_from_exp('bid', 75000, -100)
        self.assertLess(sell_price, 75000)
        buy_price = self.p1._choose_price_from_exp('ask', 25000, -100)
        self.assertGreater(buy_price, 25000)
        self.assertEqual(np.remainder(buy_price,self.p1._mpi),0)
        self.assertEqual(np.remainder(sell_price,self.p1._mpi),0)
        # mpi == 5        
        sell_price = self.p5._choose_price_from_exp('bid', 75000, -100)
        self.assertLess(sell_price, 75000)
        buy_price = self.p5._choose_price_from_exp('ask', 25000, -100)
        self.assertGreater(buy_price, 25000)
        self.assertEqual(np.remainder(buy_price,self.p5._mpi),0)
        self.assertEqual(np.remainder(sell_price,self.p5._mpi),0)
            
    def test_process_signal_Provider(self):
        time = 1
        q_provider = 0.5
        low_ru_seed = 1
        hi_ru_seed = 10
        tob_price = {'best_bid': 25000, 'best_ask': 75000}
        self.assertFalse(self.p1.quote_collector)
        self.assertFalse(self.p1.local_book)
        np.random.seed(low_ru_seed)
        self.p1.process_signal(time, tob_price, q_provider, -100)
        self.assertEqual(len(self.p1.quote_collector), 1)
        self.assertEqual(self.p1.quote_collector[0]['side'], 'buy')
        self.assertEqual(len(self.p1.local_book), 1)
        np.random.seed(hi_ru_seed)
        self.p1.process_signal(time, tob_price, q_provider, -100)
        self.assertEqual(len(self.p1.quote_collector), 1)
        self.assertEqual(self.p1.quote_collector[0]['side'], 'sell')
        self.assertEqual(len(self.p1.local_book), 2)

The MarketMaker class inherits from Provider and overrides the trader_type attribute and the confirm_trade_local() method. The MarketMaker version of confirm_trade_local() does some extra bookkeeping to track the market maker’s current position in shares. The _cumulate_cash_flow() method is called from confirm_trade_local() and stores the market maker’s current cash position.

    def test_repr_MM(self):
        self.assertEqual('MarketMaker: Trader(m1, 1, MarketMaker, 12)', 'MarketMaker: {0}'.format(self.m1))
        self.assertEqual('MarketMaker: Trader(m5, 1, MarketMaker, 12)', 'MarketMaker: {0}'.format(self.m5))
        
    def test_confirm_trade_local_MM(self):
        '''
        Test Market Maker for full and partial trade
        '''
        # MarketMaker buys
        self.m1.local_book[self.q1['order_id']] = self.q1
        self.m1.local_book[self.q2['order_id']] = self.q2
        # trade full quantity of q1
        trade1 = {'timestamp': 2, 'trader': 'p1', 'order_id': 'p1_1', 'quantity': 1, 'side': 'buy', 'price': 2000000}
        self.assertEqual(len(self.m1.local_book), 2)
        self.m1.confirm_trade_local(trade1)
        self.assertEqual(len(self.m1.local_book), 1)
        self.assertEqual(self.m1._position, 1)
        expected = {self.q2['order_id']: self.q2}
        self.assertDictEqual(self.m1.local_book, expected)
        # trade partial quantity of q2
        trade2 = {'timestamp': 3, 'trader': 'p1', 'order_id': 'p1_2', 'quantity': 2, 'side': 'buy', 'price': 2000000}
        self.m1.confirm_trade_local(trade2)
        self.assertEqual(len(self.m1.local_book), 1)
        self.assertEqual(self.m1._position, 3)
        expected = {'order_id': 'p1_2', 'timestamp': 2, 'type': 'add', 'quantity': 3, 'side': 'buy', 
                    'price': 125}
        self.assertDictEqual(self.m1.local_book.get(trade2['order_id']), expected) 
        
        # MarketMaker sells
        self.setUp()
        self.m1.local_book[self.q6['order_id']] = self.q6
        self.m1.local_book[self.q7['order_id']] = self.q7
        # trade full quantity of q6
        trade1 = {'timestamp': 6, 'trader': 'p1', 'order_id': 'p1_6', 'quantity': 1, 'side': 'sell', 'price': 0}
        self.assertEqual(len(self.m1.local_book), 2)
        self.m1.confirm_trade_local(trade1)
        self.assertEqual(len(self.m1.local_book), 1)
        self.assertEqual(self.m1._position, -1)
        expected = {self.q7['order_id']: self.q7}
        self.assertDictEqual(self.m1.local_book, expected)
        # trade partial quantity of q7
        trade2 = {'timestamp': 7, 'trader': 'p1', 'order_id': 'p1_7', 'quantity': 2, 'side': 'sell', 'price': 0}
        self.m1.confirm_trade_local(trade2)
        self.assertEqual(len(self.m1.local_book), 1)
        self.assertEqual(self.m1._position, -3)
        expected = {'order_id': 'p1_7', 'timestamp': 7, 'type': 'add', 'quantity': 3, 'side': 'sell', 
                    'price': 127}
        self.assertDictEqual(self.m1.local_book.get(trade2['order_id']), expected) 
        
    def test_cumulate_cashflow_MM(self):
        self.assertFalse(self.m1.cash_flow_collector)
        expected = {'mmid': 'm1', 'timestamp': 10, 'cash_flow': 0, 'position': 0}
        self.m1._cumulate_cashflow(10)
        self.assertDictEqual(self.m1.cash_flow_collector[0], expected)

The MarketMaker process_signal() method is tested for two possible states of the top of book: either the best price has only one share available or it has more than one share available. The method is also tested for buys and sells in a manner similar to the Provider tests. test_process_signal MM1_12() tests for a one tick price increment and the addition of 12 quotes within a pre-specified price range. The test introduces the subtest() method of unittest.TestCase within a context (i.e., with self.subtest()). To run in the for loop, the argument after the equal sign must match the loop argument. The main benefits of this strategy are that it reduces repetitive code and the complete loop will run whether the individual tests pass or not. See Doug Hellman’s Python Module of the Week for more details and a different example.

    def test_process_signal_MM1_12(self):
        time = 1
        q_provider = 0.5
        low_ru_seed = 1
        hi_ru_seed = 10
        # size > 1: market maker matches best price
        tob1 = {'best_bid': 25000, 'best_ask': 75000, 'bid_size': 10, 'ask_size': 10}
        self.assertFalse(self.m1.quote_collector)
        self.assertFalse(self.m1.local_book)
        random.seed(low_ru_seed)
        self.m1.process_signal(time, tob1, q_provider)
        self.assertEqual(len(self.m1.quote_collector), 12)
        self.assertEqual(self.m1.quote_collector[0]['side'], 'buy')
        for i in range(len(self.m1.quote_collector)):
            with self.subTest(i=i):
                self.assertLessEqual(self.m1.quote_collector[i]['price'], 25000)
                self.assertGreaterEqual(self.m1.quote_collector[i]['price'], 24941)
                self.assertTrue(self.m1.quote_collector[i]['price'] in range(24941, 25001))
        self.assertEqual(len(self.m1.local_book), 12)
        random.seed(hi_ru_seed)
        self.m1.process_signal(time, tob1, q_provider)
        self.assertEqual(len(self.m1.quote_collector), 12)
        self.assertEqual(self.m1.quote_collector[0]['side'], 'sell')
        for i in range(len(self.m1.quote_collector)):
            with self.subTest(i=i):
                self.assertLessEqual(self.m1.quote_collector[i]['price'], 75060)
                self.assertGreaterEqual(self.m1.quote_collector[i]['price'], 75000)
                self.assertTrue(self.m1.quote_collector[i]['price'] in range(75000, 75061))
        self.assertEqual(len(self.m1.local_book), 24)
        # size == 1: market maker adds liquidity one point behind
        self.setUp()
        tob2 = {'best_bid': 25000, 'best_ask': 75000, 'bid_size': 1, 'ask_size': 1}
        self.assertFalse(self.m1.quote_collector)
        self.assertFalse(self.m1.local_book)
        np.random.seed(low_ru_seed)
        self.m1.process_signal(time, tob2, q_provider)
        self.assertEqual(len(self.m1.quote_collector), 12)
        self.assertEqual(self.m1.quote_collector[0]['side'], 'buy')
        for i in range(len(self.m1.quote_collector)):
            with self.subTest(i=i):
                self.assertLessEqual(self.m1.quote_collector[i]['price'], 24999)
                self.assertGreaterEqual(self.m1.quote_collector[i]['price'], 24940)
                self.assertTrue(self.m1.quote_collector[i]['price'] in range(24940, 25000))
        self.assertEqual(len(self.m1.local_book), 12)
        np.random.seed(hi_ru_seed)
        self.m1.process_signal(time, tob2, q_provider)
        self.assertEqual(len(self.m1.quote_collector), 12)
        self.assertEqual(self.m1.quote_collector[0]['side'], 'sell')
        for i in range(len(self.m1.quote_collector)):
            with self.subTest(i=i):
                self.assertLessEqual(self.m1.quote_collector[i]['price'], 75060)
                self.assertGreaterEqual(self.m1.quote_collector[i]['price'], 75001)
                self.assertTrue(self.m1.quote_collector[i]['price'] in range(75001, 75061))
        self.assertEqual(len(self.m1.local_book), 24)

test_process_signal MM5_12() tests for a five tick price increment.

    def test_process_signal_MM5_12(self):
        time = 1
        q_provider = 0.5
        low_ru_seed = 1
        hi_ru_seed = 10
        # size > 1: market maker matches best price
        tob1 = {'best_bid': 25000, 'best_ask': 75000, 'bid_size': 10, 'ask_size': 10}
        self.assertFalse(self.m5.quote_collector)
        self.assertFalse(self.m5.local_book)
        random.seed(low_ru_seed)
        self.m5.process_signal(time, tob1, q_provider)
        self.assertEqual(len(self.m5.quote_collector), 12)
        self.assertEqual(self.m5.quote_collector[0]['side'], 'buy')
        for i in range(len(self.m5.quote_collector)):
            with self.subTest(i=i):
                self.assertLessEqual(self.m5.quote_collector[i]['price'], 25000)
                self.assertGreaterEqual(self.m5.quote_collector[i]['price'], 24935)
                self.assertTrue(self.m5.quote_collector[i]['price'] in range(24935, 25001, 5))
        self.assertEqual(len(self.m5.local_book), 12)
        random.seed(hi_ru_seed)
        self.m5.process_signal(time, tob1, q_provider)
        self.assertEqual(len(self.m5.quote_collector), 12)
        self.assertEqual(self.m5.quote_collector[0]['side'], 'sell')
        for i in range(len(self.m5.quote_collector)):
            with self.subTest(i=i):
                self.assertLessEqual(self.m5.quote_collector[i]['price'], 75065)
                self.assertGreaterEqual(self.m5.quote_collector[i]['price'], 75000)
                self.assertTrue(self.m5.quote_collector[i]['price'] in range(75000, 75066, 5))
        self.assertEqual(len(self.m5.local_book), 24)
        # size == 1: market maker adds liquidity one point behind
        self.setUp()
        tob2 = {'best_bid': 25000, 'best_ask': 75000, 'bid_size': 1, 'ask_size': 1}
        self.assertFalse(self.m5.quote_collector)
        self.assertFalse(self.m5.local_book)
        np.random.seed(low_ru_seed)
        self.m5.process_signal(time, tob2, q_provider)
        self.assertEqual(len(self.m5.quote_collector), 12)
        self.assertEqual(self.m5.quote_collector[0]['side'], 'buy')
        for i in range(len(self.m5.quote_collector)):
            with self.subTest(i=i):
                self.assertLessEqual(self.m5.quote_collector[i]['price'], 24995)
                self.assertGreaterEqual(self.m5.quote_collector[i]['price'], 24930)
                self.assertTrue(self.m5.quote_collector[i]['price'] in range(24930, 24995))
        self.assertEqual(len(self.m5.local_book), 12)
        np.random.seed(hi_ru_seed)
        self.m5.process_signal(time, tob2, q_provider)
        self.assertEqual(len(self.m5.quote_collector), 12)
        self.assertEqual(self.m5.quote_collector[0]['side'], 'sell')
        for i in range(len(self.m5.quote_collector)):
            with self.subTest(i=i):
                self.assertLessEqual(self.m5.quote_collector[i]['price'], 75065)
                self.assertGreaterEqual(self.m5.quote_collector[i]['price'], 75005)
                self.assertTrue(self.m5.quote_collector[i]['price'] in range(75005, 75065, 5))
        self.assertEqual(len(self.m5.local_book), 24)

The PennyJumper class inherits from ZITrader and implements its own confirm_trade_local() and process_signal() methods in addition to acquiring its own trader_type attribute. The test_confirm_trade_localPJ() method first populates the j1 instance _bid_quote and _ask_quote attributes so that the instance has two orders outstanding. Then it tests that the confirm_trade_local() method removes the two orders.

    def test_repr_PJ(self):
        self.assertEqual('PennyJumper: Trader(j1, 1, 5, PennyJumper)', 'PennyJumper: {0}'.format(self.j1))
        
    def test_confirm_trade_local_PJ(self):
        # PennyJumper book
        self.j1._bid_quote = {'order_id': 'j1_1', 'timestamp': 1, 'type': 'add', 'quantity': 1, 'side': 'buy',
                              'price': 125}
        self.j1._ask_quote = {'order_id': 'j1_6', 'timestamp': 6, 'type': 'add', 'quantity': 1, 'side': 'sell',
                              'price': 126}
        # trade at the bid
        trade1 = {'timestamp': 2, 'trader': 'j1', 'order_id': 'j1_1', 'quantity': 1, 'side': 'buy', 'price': 0}
        self.assertTrue(self.j1._bid_quote)
        self.j1.confirm_trade_local(trade1)
        self.assertFalse(self.j1._bid_quote)
        # trade at the ask
        trade2 = {'timestamp': 12, 'trader': 'j1', 'order_id': 'j1_6', 'quantity': 1, 'side': 'sell', 'price': 2000000}
        self.assertTrue(self.j1._ask_quote)
        self.j1.confirm_trade_local(trade2)
        self.assertFalse(self.j1._ask_quote)

The test_process_signalPJ() method tests combinations of the state of the top of the book and the penny jumper’s position relative to that state. The top of book spread can either be at the minimum price increment or larger than the minimum price increment. The penny jumper can be out of the market, alone at the inside, not alone at the inside, or behind the book.

Spread > MPI and penny jumper not in market: test add one bid and one ask.

    def test_process_signal_PJ(self):
        # spread > mpi
        tob = {'bid_size': 5, 'best_bid': 999990, 'best_ask': 1000005, 'ask_size': 5}
        # PJ book empty
        self.j1._bid_quote = None
        self.j1._ask_quote = None
        # random.seed = 1 generates random.uniform(0,1) = 0.13 then .85
        # jump the bid by 1, then jump the ask by 1
        random.seed(1)
        self.j1.process_signal(5, tob, 0.5)
        self.assertDictEqual(self.j1._bid_quote, {'order_id': 'j1_1', 'timestamp': 5, 'type': 'add', 'quantity': 1, 'side': 'buy',
                                                 'price': 999995})
        tob = {'bid_size': 1, 'best_bid': 999995, 'best_ask': 1000005, 'ask_size': 5}
        self.j1.process_signal(6, tob, 0.5)
        self.assertDictEqual(self.j1._ask_quote, {'order_id': 'j1_2', 'timestamp': 6, 'type': 'add', 'quantity': 1, 'side': 'sell',
                                                 'price': 1000000})

Spread > MPI and penny jumper alone at the inside: test do nothing.

        # PJ alone at tob
        tob = {'bid_size': 1, 'best_bid': 999995, 'best_ask': 1000000, 'ask_size': 1}
        # nothing happens
        self.j1.process_signal(7, tob, 0.5)
        self.assertDictEqual(self.j1._bid_quote, {'order_id': 'j1_1', 'timestamp': 5, 'type': 'add', 'quantity': 1, 'side': 'buy',
                                                 'price': 999995})
        self.assertDictEqual(self.j1._ask_quote, {'order_id': 'j1_2', 'timestamp': 6, 'type': 'add', 'quantity': 1, 'side': 'sell',
                                                 'price': 1000000})

Spread > MPI and penny jumper behind the book: test cancel outstanding orders, add new orders.

        # PJ bid and ask behind the book
        tob = {'bid_size': 1, 'best_bid': 999990, 'best_ask': 1000005, 'ask_size': 1}
        self.j1._bid_quote = {'order_id': 'j1_1', 'timestamp': 5, 'type': 'add', 'quantity': 1, 'side': 'buy',
                             'price': 999985}
        self.j1._ask_quote = {'order_id': 'j1_2', 'timestamp': 6, 'type': 'add', 'quantity': 1, 'side': 'sell',
                             'price': 1000010}
        # random.seed = 1 generates random.uniform(0,1) = 0.13 then .85
        # jump the bid by 1, then jump the ask by 1; cancel old quotes
        random.seed(1)
        self.j1.process_signal(10, tob, 0.5)
        self.assertDictEqual(self.j1._bid_quote, {'order_id': 'j1_3', 'timestamp': 10, 'type': 'add', 'quantity': 1, 'side': 'buy',
                                                 'price': 999995})
        self.assertDictEqual(self.j1.cancel_collector[0], {'order_id': 'j1_1', 'timestamp': 10, 'type': 'cancel', 'quantity': 1, 'side': 'buy',
                                                            'price': 999985})
        self.assertDictEqual(self.j1.quote_collector[0], self.j1._bid_quote)
        self.j1.process_signal(11, tob, 0.5)
        self.assertDictEqual(self.j1._ask_quote, {'order_id': 'j1_4', 'timestamp': 11, 'type': 'add', 'quantity': 1, 'side': 'sell',
                                                 'price': 1000000})
        self.assertDictEqual(self.j1.cancel_collector[0], {'order_id': 'j1_2', 'timestamp': 11, 'type': 'cancel', 'quantity': 1, 'side': 'sell',
                                                           'price': 1000010})
        self.assertDictEqual(self.j1.quote_collector[0],self.j1._ask_quote)

Spread > MPI and penny jumper not alone at the inside: test cancel outstanding orders, add new orders.

        # PJ not alone at the inside
        tob = {'bid_size': 5, 'best_bid': 999990, 'best_ask': 1000010, 'ask_size': 5}
        self.j1._bid_quote = {'order_id': 'j1_1', 'timestamp': 5, 'type': 'add', 'quantity': 1, 'side': 'buy',
                             'price': 999990}
        self.j1._ask_quote = {'order_id': 'j1_2', 'timestamp': 6, 'type': 'add', 'quantity': 1, 'side': 'sell',
                             'price': 1000010}
        # random.seed = 1 generates random.uniform(0,1) = 0.13 then .85
        # jump the bid by 1, then jump the ask by 1; cancel old quotes
        random.seed(1)
        self.j1.process_signal(12, tob, 0.5)
        self.assertDictEqual(self.j1._bid_quote, {'order_id': 'j1_5', 'timestamp': 12, 'type': 'add', 'quantity': 1, 'side': 'buy',
                                                 'price': 999995})
        self.assertDictEqual(self.j1.cancel_collector[0], {'order_id': 'j1_1', 'timestamp': 12, 'type': 'cancel', 'quantity': 1, 'side': 'buy',
                                                           'price': 999990})
        self.assertDictEqual(self.j1.quote_collector[0], self.j1._bid_quote)
        self.j1.process_signal(13, tob, 0.5)
        self.assertDictEqual(self.j1._ask_quote, {'order_id': 'j1_6', 'timestamp': 13, 'type': 'add', 'quantity': 1, 'side': 'sell',
                                                 'price': 1000005})
        self.assertDictEqual(self.j1.cancel_collector[0], {'order_id': 'j1_2', 'timestamp': 13, 'type': 'cancel', 'quantity': 1, 'side': 'sell',
                                                           'price': 1000010})
        self.assertDictEqual(self.j1.quote_collector[0],self.j1._ask_quote)

Spread == MPI and penny jumper alone at the inside: test do nothing.

        # spread at mpi, PJ alone at nbbo
        tob = {'bid_size': 1, 'best_bid': 999995, 'best_ask': 1000000, 'ask_size': 1}
        self.j1._bid_quote = {'order_id': 'j1_1', 'timestamp': 5, 'type': 'add', 'quantity': 1, 'side': 'buy',
                             'price': 999995}
        self.j1._ask_quote = {'order_id': 'j1_2', 'timestamp': 6, 'type': 'add', 'quantity': 1, 'side': 'sell',
                             'price': 1000000}
        random.seed(1)
        self.j1.process_signal(14, tob, 0.5)
        self.assertDictEqual(self.j1._bid_quote, {'order_id': 'j1_1', 'timestamp': 5, 'type': 'add', 'quantity': 1, 'side': 'buy',
                                                 'price': 999995})
        self.assertFalse(self.j1.cancel_collector)
        self.assertFalse(self.j1.quote_collector)
        self.j1.process_signal(15, tob, 0.5)
        self.assertDictEqual(self.j1._ask_quote, {'order_id': 'j1_2', 'timestamp': 6, 'type': 'add', 'quantity': 1, 'side': 'sell',
                                                 'price': 1000000})
        self.assertFalse(self.j1.cancel_collector)
        self.assertFalse(self.j1.quote_collector)

Spread == MPI and penny jumper behind the book: test cancel both.

        # PJ bid and ask behind the book
        self.j1._bid_quote = {'order_id': 'j1_1', 'timestamp': 5, 'type': 'add', 'quantity': 1, 'side': 'buy',
                             'price': 999990}
        self.j1._ask_quote = {'order_id': 'j1_2', 'timestamp': 6, 'type': 'add', 'quantity': 1, 'side': 'sell',
                             'price': 1000010}
        # random.seed = 1 generates random.uniform(0,1) = 0.13 then .85
        # cancel bid and ask
        random.seed(1)
        self.assertTrue(self.j1._bid_quote)
        self.assertTrue(self.j1._ask_quote)
        self.j1.process_signal(16, tob, 0.5)
        self.assertFalse(self.j1._bid_quote)
        self.assertFalse(self.j1._ask_quote)
        self.assertDictEqual(self.j1.cancel_collector[0], {'order_id': 'j1_1', 'timestamp': 16, 'type': 'cancel', 'quantity': 1, 'side': 'buy',
                                                           'price': 999990})
        self.assertDictEqual(self.j1.cancel_collector[1], {'order_id': 'j1_2', 'timestamp': 16, 'type': 'cancel', 'quantity': 1, 'side': 'sell',
                                                           'price': 1000010})
        self.assertFalse(self.j1.quote_collector)

As usual, the tests are much longer than the code to be tested. If you have comments or suggestions, feel free to post them. Coming up are posts describing the simulation loop and a wrapper file to replicate the results in the Working Paper.

Coding some zero-intelligence traders with Python

This blog continues the coding project in support of replicating the results in my Tick Pilot Agent-Based Modeling paper. The first blog introduced the limit order book and the second described unit testing the order book. The next step is coding up some zero-intelligence traders – traders who follow simple rules and act randomly. The strategy employs object-oriented programming to enforce a single channel of communication from the trader to the order book and to reuse code whenever possible. There are several classes representing four basic trader types:

  1. The ZITrader class is the base class from which the others inherit. This class defines the communication mechanism (i.e., the order) and enforces the idea that this is the only way a trader can send messages to the order book.
  2. Two simple liquidity provider classes: Provider and Provider5, with Provider inheriting from ZITrader and Provider5 inheriting from Provider.
  3. Two market maker classes: MarketMaker and MarketMaker5, with MarketMaker inheriting from Provider and MarketMaker5 inheriting from MarketMaker.
  4. A Taker class inherits from ZITrader.
  5. A PennyJumper class inherits from ZITrader.

The full code is available on GitHub as trader2017_r3.py. As usual, the first step is to import some python modules.

import random
import numpy as np

The base class is ZITrader. It contains the _make_add_quote() method and some supporting infrastructure for making and storing quotes.

class ZITrader(object):
    '''
    ZITrader generates quotes (dicts) based on mechanical probabilities.
    
    A general base class for specific trader types.
    Public attributes: quote_collector
    Public methods: none
    '''

    def __init__(self, name, maxq):
        '''
        Initialize ZITrader with some base class attributes and a method
        
        quote_collector is a public container for carrying quotes to the exchange
        '''
        self._trader_id = name # trader id
        self._max_quantity = maxq
        self.quote_collector = []
        self._quote_sequence = 0
        
    def __repr__(self):
        return 'Trader({0}, {1})'.format(self._trader_id, self._max_quantity)
        
    def _make_add_quote(self, time, quantity, side, price):
        '''Make one add quote (dict)'''
        self._quote_sequence += 1
        order_id = '%s_%d' % (self._trader_id, self._quote_sequence)
        return {'order_id': order_id, 'timestamp': time, 'type': 'add', 'quantity': quantity, 
                'side': side, 'price': price}

In this simple model, I chose to make the quote a Python dict. In a more complex model, the actual quote could have been an instance of a separate class with ‘.’ access to the attributes.

Instances of the Provider class must be capable of several activities: generating inputs to the _make_add_quote() method, receiving confirmation messages from the order book, and canceling outstanding orders.

class Provider(ZITrader):
    '''
    Provider generates quotes (dicts) based on make probability.
    
    Subclass of ZITrader
    Public attributes: trader_type, quote_collector (from ZITrader), cancel_collector, local_book
    Public methods: confirm_cancel_local, confirm_trade_local, process_signal, bulk_cancel
    '''

    def __init__(self, name, maxq, mpi, delta):
        '''Provider has own mpi and delta; a local_book to track outstanding orders and a 
        cancel_collector to convey cancel messages to the exchange.
        '''
        ZITrader.__init__(self, name, maxq)
        self.trader_type = 'Provider'
        self._mpi = mpi
        self._delta = delta
        self.local_book = {}
        self.cancel_collector = []
                
    def __repr__(self):
        return 'Trader({0}, {1}, {2})'.format(self._trader_id, self._max_quantity, self.trader_type)
    
    def _make_cancel_quote(self, q, time):
        return {'type': 'cancel', 'timestamp': time, 'order_id': q['order_id'], 'quantity': q['quantity'],
                'side': q['side'], 'price': q['price']}
        
    def confirm_cancel_local(self, cancel_dict):
        del self.local_book[cancel_dict['order_id']]

    def confirm_trade_local(self, confirm):
        to_modify = self.local_book.get(confirm['order_id'], "WTF???")
        if confirm['quantity'] == to_modify['quantity']:
            self.confirm_cancel_local(to_modify)
        else:
            self.local_book[confirm['order_id']]['quantity'] -= confirm['quantity']
            
    def bulk_cancel(self, time):
        '''bulk_cancel cancels _delta percent of outstanding orders'''
        self.cancel_collector.clear()
        lob = len(self.local_book)
        if lob > 0:
            order_keys = list(self.local_book.keys())
            orders_to_delete = np.random.ranf(lob)
            for idx in range(lob):
                if orders_to_delete[idx] < self._delta:
                    self.cancel_collector.append(self._make_cancel_quote(self.local_book.get(order_keys[idx]), time))

    def process_signal(self, time, qsignal, q_provider, lambda_t):
        '''Provider buys or sells with probability related to q_provide'''
        self.quote_collector.clear()
        if np.random.uniform(0,1) < q_provider:
            price = self._choose_price_from_exp('bid', qsignal['best_ask'], lambda_t)
            side = 'buy'
        else:
            price = self._choose_price_from_exp('ask', qsignal['best_bid'], lambda_t)
            side = 'sell'
        q = self._make_add_quote(time, self._max_quantity, side, price)
        self.local_book[q['order_id']] = q
        self.quote_collector.append(q)            
      
    def _choose_price_from_exp(self, side, inside_price, lambda_t):
        '''Prices chosen from an exponential distribution'''
        # make pricing explicit for now. Logic scales for other mpi.
        plug = np.int(lambda_t*np.log(np.random.rand()))
        if side == 'bid':
            #price = np.int(5*np.floor((inside_price-1-plug)/5))
            price = inside_price-1-plug
        else:
            #price = np.int(5*np.ceil((inside_price+1+plug)/5))
            price = inside_price+1+plug
        return price

The process_signal() method randomly chooses whether to buy or sell and calls _choose_price_from_exp() to establish the price. Then it does some bookkeeping by adding the quote (dict) to the individual trader’s local book and to the quote_collector, a list that conveys the message to the order book. _choose_price_from_exp() randomly selects a price increment (plug) from an exponential distribution and then computes a price based on the distance from the best price on the opposite side of the market. This design has a purpose: Providers never cross the spread! bulk_cancel() and _make_cancel_quote() randomly select some quotes on the local book to be canceled and pass the cancel messages on to the exchange in the cancel_collector list. The two remaining methods, confirm_cancel_local() and confirm_trade_local() receive messages from the order book and modify the local book appropriately.

One of the contributions of the Working Paper is quantifying the impact of increasing the minimum pricing increment from one to five ticks on some market quality measures and market maker profitability. Provider5 enforces pricing on a five tick grid by employing its own _choose_price_from_exp() method.

class Provider5(Provider):
    '''
    Provider5 generates quotes (dicts) based on make probability.
    
    Subclass of Provider
    '''

    def __init__(self, name, maxq, mpi, delta):
        '''Provider has own mpi and delta; a local_book to track outstanding orders and a 
        cancel_collector to convey cancel messages to the exchange.
        '''
        Provider.__init__(self, name, maxq, mpi, delta)

    def _choose_price_from_exp(self, side, inside_price, lambda_t):
        '''Prices chosen from an exponential distribution'''
        # make pricing explicit for now. Logic scales for other mpi.
        plug = np.int(lambda_t*np.log(np.random.rand()))
        if side == 'bid':
            price = np.int(5*np.floor((inside_price-1-plug)/5))
        else:
            price = np.int(5*np.ceil((inside_price+1+plug)/5))
        return price

The MarketMaker is a special type of liquidity provider that inherits most of its behavior from Provider. Instances of the MarketMaker class submit multiple orders with prices chosen from uniform distribution. process_signal() accomplishes this by choosing a number of prices (_num_quotes) from a range of prices defined by the best price and the upper/lower limit (_quote_range). One of the research questions in the Working Paper focused on market maker profitability. Instances of MarketMaker track profitability by adding a few attributes (_position, _cash_flow, and cash_flow_collector), overriding the confirm_trade_local() method, and employing the _cumulate_cashflow() helper method.

class MarketMaker(Provider):
    '''
    MarketMaker generates a series of quotes near the inside (dicts) based on make probability.
    
    Subclass of Provider
    Public attributes: trader_type, quote_collector (from ZITrader), cancel_collector (from Provider),
    cash_flow_collector
    Public methods: confirm_cancel_local (from Provider), confirm_trade_local, process_signal 
    '''

    def __init__(self, name, maxq, mpi, delta, num_quotes, quote_range):
        '''_num_quotes and _quote_range determine the depth of MM quoting;
        _position and _cashflow are stored MM metrics
        '''
        Provider.__init__(self, name, maxq, mpi, delta)
        self.trader_type = 'MarketMaker'
        self._num_quotes = num_quotes
        self._quote_range = quote_range
        self._position = 0
        self._cash_flow = 0
        self.cash_flow_collector = []
                      
    def __repr__(self):
        return 'Trader({0}, {1}, {2}, {3})'.format(self._trader_id, self._max_quantity, self.trader_type, self._num_quotes)
            
    def confirm_trade_local(self, confirm):
        '''Modify _cash_flow and _position; update the local_book'''
        if confirm['side'] == 'buy':
            self._cash_flow -= confirm['price']*confirm['quantity']
            self._position += confirm['quantity']
        else:
            self._cash_flow += confirm['price']*confirm['quantity']
            self._position -= confirm['quantity']
        to_modify = self.local_book.get(confirm['order_id'], "WTF???")
        if confirm['quantity'] == to_modify['quantity']:
            self.confirm_cancel_local(to_modify)
        else:
            self.local_book[confirm['order_id']]['quantity'] -= confirm['quantity']
        self._cumulate_cashflow(confirm['timestamp'])
         
    def _cumulate_cashflow(self, timestamp):
        self.cash_flow_collector.append({'mmid': self._trader_id, 'timestamp': timestamp, 'cash_flow': self._cash_flow,
                                         'position': self._position})
            
    def process_signal(self, time, qsignal, q_provider):
        '''
        MM chooses prices from a grid determined by the best prevailing prices.
        MM never joins the best price if it has size=1.
        ''' 
        # make pricing explicit for now. Logic scales for other mpi and quote ranges.
        self.quote_collector.clear()
        if random.uniform(0,1) < q_provider:
            max_bid_price = qsignal['best_bid'] if qsignal['bid_size'] > 1 else qsignal['best_bid']-self._mpi
            prices = np.random.choice(range(max_bid_price-self._quote_range+1, max_bid_price+1, self._mpi), size=self._num_quotes)
            side = 'buy'
        else:
            min_ask_price = qsignal['best_ask'] if qsignal['ask_size'] > 1 else qsignal['best_ask']+self._mpi
            prices = np.random.choice(range(min_ask_price, min_ask_price+self._quote_range, self._mpi), size=self._num_quotes)
            side = 'sell'
        for price in prices:
            q = self._make_add_quote(time, self._max_quantity, side, price)
            self.local_book[q['order_id']] = q
            self.quote_collector.append(q)

MarketMaker5 enforces pricing on a five tick grid by overriding the MarketMaker process_signal() method. Two new attributes, _p5ask and _p5bid, assign probabilities to the discrete uniform price grid, thereby establishing the idea that the true reservation prices are still formulated on a one-tick grid. See the Working Paper for more details.

class MarketMaker5(MarketMaker):
    '''
    MarketMaker5 generates a series of quotes near the inside (dicts) based on make probability.
    
    Subclass of MarketMaker
    Public methods: process_signal 
    '''
    
    def __init__(self, name, maxq, mpi, delta, num_quotes, quote_range):
        '''
        _num_quotes and _quote_range determine the depth of MM quoting;
        _position and _cashflow are stored MM metrics
        '''
        MarketMaker.__init__(self, name, maxq, mpi, delta, num_quotes, quote_range)
        self._p5ask = [1/20, 1/12, 1/12, 1/12, 1/12, 1/12, 1/12, 1/12, 1/12, 1/12, 1/12, 1/12, 1/30]
        self._p5bid = [1/30, 1/12, 1/12, 1/12, 1/12, 1/12, 1/12, 1/12, 1/12, 1/12, 1/12, 1/12, 1/20]
               
    def process_signal(self, time, qsignal, q_provider):
        '''
        MM chooses prices from a grid determined by the best prevailing prices.
        MM never joins the best price if it has size=1.
        ''' 
        # make pricing explicit for now. Logic scales for other mpi and quote ranges.
        self.quote_collector.clear()
        if random.uniform(0,1) < q_provider:
            max_bid_price = qsignal['best_bid'] if qsignal['bid_size'] > 1 else qsignal['best_bid']-self._mpi
            prices = np.random.choice(range(max_bid_price-self._quote_range, max_bid_price+1, self._mpi), size=self._num_quotes, p=self._p5bid)
            side = 'buy'
        else:
            min_ask_price = qsignal['best_ask'] if qsignal['ask_size'] > 1 else qsignal['best_ask']+self._mpi
            prices = np.random.choice(range(min_ask_price, min_ask_price+self._quote_range+1, self._mpi), size=self._num_quotes, p=self._p5ask)
            side = 'sell'
        for price in prices:
            q = self._make_add_quote(time, self._max_quantity, side, price)
            self.local_book[q['order_id']] = q
            self.quote_collector.append(q)

The PennyJumper is also a liquidity provider, but with a simple rule: either be alone at the best (inside) price or leave the market. The PennyJumper can have a maximum of two quotes outstanding: _ask_quote and _bid_quote. The quoting rule is implemented in its own process_signal() method. After clearing out the collectors, the method checks for the existence of an available price at the inside. If there is one, then the PennyJumper randomly chooses the side of the quote. It then checks whether it is alone at the best price and cancels if not. If the PennyJumper has no quote (self._bid_quote is None, for example) then the PennyJumper adds a new quote to establish the best inside price. If there is no available price at the inside (i.e., the spread is equal to the minimum price increment), then the PennyJumper checks whether it is alone at the inside and cancels if not.

class PennyJumper(ZITrader):
    '''
    PennyJumper jumps in front of best quotes when possible
    
    Subclass of ZITrader
    Public attributes: trader_type, quote_collector (from ZITrader), cancel_collector
    Public methods: confirm_trade_local (from ZITrader)
    '''
    
    def __init__(self, name, maxq, mpi):
        '''
        Initialize PennyJumper
        
        cancel_collector is a public container for carrying cancel messages to the exchange
        PennyJumper tracks private _ask_quote and _bid_quote to determine whether it is alone
        at the inside or not.
        '''
        ZITrader.__init__(self, name, maxq)
        self.trader_type = 'PennyJumper'
        self._mpi = mpi
        self.cancel_collector = []
        self._ask_quote = None
        self._bid_quote = None
        
    def __repr__(self):
        return 'Trader({0}, {1}, {2}, {3})'.format(self._trader_id, self._max_quantity, self._mpi, self.trader_type)
    
    def _make_cancel_quote(self, q, time):
        return {'type': 'cancel', 'timestamp': time, 'order_id': q['order_id'], 'quantity': q['quantity'],
                'side': q['side'], 'price': q['price']}

    def confirm_trade_local(self, confirm):
        '''PJ has at most one bid and one ask outstanding - if it executes, set price None'''
        if confirm['side'] == 'buy':
            self._bid_quote = None
        else:
            self._ask_quote = None
            
    def process_signal(self, time, qsignal, q_taker):
        '''PJ determines if it is alone at the inside, cancels if not and replaces if there is an available price 
        point inside the current quotes.
        '''
        self.quote_collector.clear()
        self.cancel_collector.clear()
        if qsignal['best_ask'] - qsignal['best_bid'] > self._mpi:
            # q_taker > 0.5 implies greater probability of a buy order; PJ jumps the bid
            if random.uniform(0,1) < q_taker:
                if self._bid_quote: # check if not alone at the bid
                    if self._bid_quote['price'] < qsignal['best_bid'] or self._bid_quote['quantity'] < qsignal['bid_size']:
                        self.cancel_collector.append(self._make_cancel_quote(self._bid_quote, time))
                        self._bid_quote = None
                if not self._bid_quote:
                    price = qsignal['best_bid'] + self._mpi
                    side = 'buy'
                    q = self._make_add_quote(time, self._max_quantity, side, price)
                    self.quote_collector.append(q)
                    self._bid_quote = q
            else:
                if self._ask_quote: # check if not alone at the ask
                    if self._ask_quote['price'] > qsignal['best_ask'] or self._ask_quote['quantity'] < qsignal['ask_size']:
                        self.cancel_collector.append(self._make_cancel_quote(self._ask_quote, time))
                        self._ask_quote = None
                if not self._ask_quote:
                    price = qsignal['best_ask'] - self._mpi
                    side = 'sell'
                    q = self._make_add_quote(time, self._max_quantity, side, price)
                    self.quote_collector.append(q)
                    self._ask_quote = q
        else: # spread = mpi
            if self._bid_quote: # check if not alone at the bid
                if self._bid_quote['price'] < qsignal['best_bid'] or self._bid_quote['quantity'] < qsignal['bid_size']:
                    self.cancel_collector.append(self._make_cancel_quote(self._bid_quote, time))
                    self._bid_quote = None
            if self._ask_quote: # check if not alone at the ask
                if self._ask_quote['price'] > qsignal['best_ask'] or self._ask_quote['quantity'] < qsignal['ask_size']:
                    self.cancel_collector.append(self._make_cancel_quote(self._ask_quote, time))
                    self._ask_quote = None

The Taker is the only liquidity taker in this model. process_signal() randomly chooses whether to buy or sell then makes an add quote guaranteed to cross the spread and take liquidity by choosing a price equal to zero for sells and 2,000,000 for buys.

class Taker(ZITrader):
    '''
    Taker generates quotes (dicts) based on take probability.
        
    Subclass of ZITrader
    Public attributes: trader_type, quote_collector (from ZITrader)
    Public methods: process_signal 
    '''

    def __init__(self, name, maxq):
        ZITrader.__init__(self, name, maxq)
        self.trader_type = 'Taker'
        
    def __repr__(self):
        return 'Trader({0}, {1}, {2})'.format(self._trader_id, self._max_quantity, self.trader_type)
        
    def process_signal(self, time, q_taker):
        '''Taker buys or sells with 50% probability.'''
        self.quote_collector.clear()
        if random.uniform(0,1) < q_taker: # q_taker > 0.5 implies greater probability of a buy order
            price = 2000000 # agent buys at max price (or better)
            side = 'buy'
        else:
            price = 0 # agent sells at min price (or better)
            side = 'sell'
        q = self._make_add_quote(time, self._max_quantity, side, price)
        self.quote_collector.append(q)

Additional trader types can be added to this model. The only necessity is that they generate the information required for _make_add_quote(). You may also want to augment your traders with additional bookkeeping tasks. If so, you can employ or modify the strategy demonstrated in MarketMaker. If you choose to make new liquidity providers, then adding the ability to cancel outstanding orders and track their own trading activity might also be useful. If you have comments or suggestions, please post them. The current WordPress settings require me to approve the first comment from a specific source. After that, you are free to comment without further approval. Coming up are posts describing the Trader unit tests followed by a post or two on the simulation loop.