0

I'm building an agent-based model that simulates a stock market in which different participants have semi-randomized behavior. For some reason, multiple agents of the same class all engage in identical trading behavior.

Basic question is: why do all instances of my class update their state, instead of just one instance?

Below, I'm posting the code I'm using. After that, some explanatory notes so it's more clear what's going on.


Here's the code for the model itself, which uses some elements of the Mesa agent-based modeling library:

    '''
    Simulate a market. Create company and trader agents, simulate trades,
    track macro variables and returns.
    '''
    def __init__(self,
                 companies=10, # TK All of these are temp values
                 noisetraders=2,
                 marketmakers=5,
                 fundamentaltraders=0,
                 systematictraders=0,
                 passivetraders=0,
                 rfr = 0.02,
                 days = 5000):
        super().__init__()
        self.time = 0 
        self.days = days # length of simulation
        # Macro variables
        self.widget_price = 10.00
        self.rfr = rfr

        self.companies_list = [Company(str(x), self) for x in range(companies)]
        self.orderbook = []
        # pre-populating trades list so market-makers start somewhere
        self.tape = [(x.ticker, 100, 10.00, 0) for x in self.companies_list]

        # Update traders list
        self.mm_list = [MarketMaker(x, self) for x in range(marketmakers)]
        self.traders_list = [NoiseTrader(x, self) for x in range(noisetraders)]
        self.traders_list += [FundamentalTrader(x, self) for x in range(fundamentaltraders)]
        self.traders_list += [SystematicTrader(x, self) for x in range(systematictraders)]
        self.traders_list += [PassiveTrader(x, self) for x in range(passivetraders)]

    def step(self):
        self.time += 1
        self.orderbook = [] # all orders GTC for now
        if self.widget_price >= 1.00:
            self.widget_price *= (1.0+ random.normalvariate(.0001, 0.01)) 
        else:
            self.widget_price += 0.01
        for trader in self.traders_list:
            for mm in self.mm_list:
                mm.step()
            trader.step()

    def run_model(self):
        for i in range(self.days):
            self.time = i
            self.step()

Here's what an Order object looks like:

class Order:
    '''
    An order. All orders are limit orders, but for now traders submit 
    limit orders at the bid/ask.

    Orders can be submitted at arbitrary precision, but must be rounded.

    Orders below 0 converted to 0.01
    '''
    def __init__(self, otype, trader, ticker, px, qt, model):
        self.otype = otype
        self.trader = trader
        self.ticker = ticker
        self.px = max(round(px, 2), 0.01)
        self.qt = qt
        self.model = model

    def __repr__(self):
        return self.otype+' '+str(self.qt)+' '+self.ticker+' @ '+str(self.px)

    def execute(self, counterparty):
        if self.otype == 'buy':
            self.trader.buy(self.model, counterparty, self.ticker, self.qt, self.px)
        else:
            self.trader.sell(self.model, counterparty, self.ticker, self.qt, self.px)
        self.model.orderbook.remove(self)

Code for the basic trader class:

class Trader(Agent):
    '''
    A high-level class from which all specific market actors inherit.
    '''
    def __init__(self,
                 unique_id,
                 model,
                 cash = 10000.00,
                 positions = defaultdict(int),
                 gross_range = (0,2.0),
                 net_range = (-1.0,1.0),
                 confidence = random.random()):
        self.unique_id = unique_id
        self.model = model
        self.cash = cash
        self.positions = positions
        self.gross_range = gross_range
        self.net_range =  net_range
        self.confidence = confidence # a general confidence indicator
        self.wealth = self.cash + sum([self.positions[ticker] * self.model.last_px(ticker) for ticker in list(self.positions.keys())])
        self.wealth_hist = [(0, cash)]

    def buy(self, model, seller, ticker, shares, price):
        """Execute a buy trade."""
        if ticker not in [x.ticker for x in self.model.companies_list]:
            raise Exception("Invalid ticker")
        value = shares * price
        self.cash -= value
        self.positions[ticker] += shares
        seller.cash += value
        seller.positions[ticker] -= shares
        if self.positions[ticker] == 0:
            del self.positions[ticker]
        if seller.positions[ticker] == 0:
            del seller.positions[ticker]
        self.model.tape.append((ticker, shares, price, self.model.time))

    def sell(self, model, buyer, ticker, shares, price):
        """Execute a sell trade."""
        if ticker not in [x.ticker for x in self.model.companies_list]:
            raise Exception("Invalid ticker")
        value = shares * price
        self.cash += value
        self.positions[ticker] -= shares
        buyer.cash -= value
        buyer.positions[ticker] += shares
        if self.positions[ticker] == 0:
            del self.positions[ticker]
        if buyer.positions[ticker] == 0:
            del buyer.positions[ticker]
        self.model.tape.append((ticker, shares, price, self.model.time))

    def net_exposure(self):
        '''Returns % net long position'''
        net = sum([self.positions[x] * m.last_px(x) for x in self.positions])
        return net / self.wealth

    def gross_exposure(self):
        '''Returns % gross position value'''
        gross = sum([abs(self.positions[x] * m.last_px(x)) for x in self.positions])
        return gross / self.wealth

    def check_margin(self):
        '''Return True if margin is below maintenance margin.'''
        gross_long = sum([self.positions[x] * m.last_px(x) for x in self.postitions if self.positions[x] >= 0])
        gross_short = sum([self.positions[x] * m.last_px(x) for x in self.postitions if self.positions[x] < 0])
        if self.cash >= self.wealth:
            self.gross_short / self.wealth  < 3
        else:
            self.gross_long / self.wealth < 3

    def buying_power(self):
        '''Total amount investor can go long.'''
        max_gross = self.wealth * self.gross_range[1] - self.gross_exposure()
        max_net = self.wealth * self.net_range[1] - self.max_exposure()
        return min(max_gross, max_net)

    def selling_power(self):
        '''Total amount trader call sell (or short).'''
        min_net = self.net_exposure() - self.wealth * self.net_range[0]

    def margin_liquidation(self, model):
        '''
        If trade is below maintenance margin, liquididate all positions.

        Note: this function is overridden for market-makers.
        '''
        for p in self.positions:
            if self.positions[p] > 0:
                model.top_of_book(p, 'sell').execute(self)
            if self.positions[p] < 0:
                model.top_of_book(p, 'buy').execute(self)

    def step(self):
        '''
        We allow time to start negative so companies can operate for a while
        and create fundamental data before traders trade.
        '''
        if self.model.time < 0:
            pass
        else:
            self.cash = self.cash*(1 + self.model.rfr) ** (1/252)
            self.wealth_hist.append((self.model.time, self.wealth))

Code for noise traders:

class NoiseTrader(Trader):
    '''
    A trader who makes trades mostly at random.

    These will be our main source of noise, so we'll vary their number to 
    calibrate the model.    
    '''

    def __init__(self,
                 unique_id,
                 model,
                 cash = 10000.00,
                 positions = defaultdict(int),
                 gross_range = (0,2.0),
                 net_range = (-1.0,1.0),
                 confidence = random.random()):
        super().__init__(unique_id,
                         model,
                         cash,
                         positions,
                         gross_range,
                         net_range,
                         confidence)

    def random_buy(self):
        ticker = random.choice([c.ticker for c in self.model.companies_list])
        try:
            m.top_of_book(ticker, 'buy').execute(self)
        except:
            print("Buy failed! Ticker: ", ticker)

    def random_sell(self):
        ticker = random.choice([c.ticker for c in self.model.companies_list])
        try:
            m.top_of_book(ticker, 'sell').execute(self)
        except:
            print("Sell failed! Ticker: ", ticker)

    def step(self):
        self.cash = self.cash*(1 + self.model.rfr) ** (1/252)
        if self.gross_exposure() > self.gross_range[1] or self.net_exposure() > self.net_range[1]:
            self.random_sell()
            if self.net_exposure() < self.net_range[0]:
                self.random_buy()
            else:
                random.choice([self.random_buy(), self.random_sell()])
        else:
            random.choice([self.random_buy(), self.random_sell()])
        self.wealth = self.cash + sum([self.positions[ticker] * self.model.last_px(ticker) for ticker in list(self.positions.keys())])
        self.wealth_hist.append((self.model.time, self.wealth))

And for market-makers:

class MarketMaker(Trader):
    '''Class representing a market-maker or specialist

    In this model, market-makers are responsible for posting all limit orders, 
    i.e. they provide 100% of liquidity. Market-makers have additional risk 
    criteria:

    - Overnight risk represents their preference for holding a position vs
    going to flat.
    - Net risk represents their willingness to be net long/short.

    Both preferences affect the width/direction of spreads they quote.
    '''
    def __init__(self,
                 unique_id,
                 model,
                 cash = 10000.00,
                 positions = defaultdict(int),
                 gross_range = (0,2.0),
                 net_range = (-1.0,1.0),
                 confidence = random.random(),
                 overnight_risk = random.random(),
                 spread = 0.1): # quoting a 10% wide spread initially
        super().__init__(unique_id,
                         model,
                         cash,
                         positions,
                         gross_range,
                         net_range,
                         confidence)
        self.confidence = random.random() 
        self.overnight_risk = overnight_risk
        # have to run these again to get new value
        # self.owned = [(c[0], self.model.last_px(c[0])) for c in self.positions]
        self.spread = spread 

    def update_risks(self):
        '''
        Update risk tolerance.

        Risk tolerance approaches 1 as wealth rises, or 0 as it falls.
        '''
        if self.wealth >= sum([x[1] for x in self.wealth_hist[:5]])/len(self.wealth_hist[:5]):
            self.confidence += 0.5 * (1-self.confidence)
            self.overnight_risk += 0.5 * (1-self.overnight_risk)
        else:
            self.confidence -= 0.5 * self.confidence
            self.overnight_risk -= 0.5 * self.overnight_risk

    def update_quotes(self):
        '''
        Market maker updates order book, quoting a spread in each name based on 
        their position and risk-tolerance.
        '''
        tape = self.model.tape
        lasts = [(c.ticker, self.model.last_px(c.ticker)) for c in self.model.companies_list]
        owned = [(c, self.model.last_px(c)) for c in self.positions]
        unowned = [c for c in lasts if c not in owned]
        if (not unowned):
            pass
        else:
            for co in unowned:
                self.model.orderbook.append(Order('buy',
                                                  self,
                                                  co[0],
                                                  co[1]-(self.spread/(2 * self.confidence)),
                                                  100,
                                                  self.model))
                self.model.orderbook.append(Order('sell',
                                                  self,
                                                  co[0],
                                                  co[1]+(self.spread/(2 * self.confidence)),
                                                  100,
                                                  self.model))
        if (not owned):
            pass
        else:
            for co in owned:
                if self.positions[co[0]] > 0:
                    adj_midpoint = co[1] - (self.spread/2 * self.confidence * self.overnight_risk)
                    self.model.orderbook.append(Order('buy',
                                                      self,
                                                      co[0],
                                                      adj_midpoint - (self.spread/2 * self.confidence),
                                                      100,
                                                      self.model))
                    self.model.orderbook.append(Order('sell',
                                                      self,
                                                      co[0],
                                                      adj_midpoint + (self.spread/2 * self.confidence),
                                                      100,
                                                      self.model))
                else:
                    adj_midpoint = co[1] + (self.spread/2 * self.confidence * self.overnight_risk)
                    self.model.orderbook.append(Order('buy',
                                                      self,
                                                      co[0],
                                                      adj_midpoint - (self.spread/2 * self.confidence),
                                                      100,
                                                      self.model))
                    self.model.orderbook.append(Order('sell',
                                                      self,
                                                      co[0],
                                                      adj_midpoint + (self.spread/2 * self.confidence),
                                                      100,
                                                      self.model))

    def margin_liquidation(self):
        '''
        If a market-maker runs out of maintenance margin, they liquidate by
        offering extremely wide quotes on anything not in their book, and
        and quoting midpoint-vs-extreme on anything still in their book.
        '''
        pass        

    def step(self):
        self.cash = self.cash*(1 + self.model.rfr) ** (1/252)
        self.update_risks()
        self.update_quotes()
        self.wealth = self.cash + sum([self.positions[ticker] * self.model.last_px(ticker) for ticker in list(self.positions.keys())])
        self.wealth_hist.append((self.model.time, self.wealth))

So how is this supposed to work?

To simplify things, I have two kinds of traders: market-makers do what traditional market-makers and exchange specialists do; for every stock they trade, they quote a price at which they'll buy and a price at which they'll sell. They make money from the expectation that orders are pretty much random. In this model, they behave with a simplified version of real-world market-making behavior: when they're optimistic (i.e. making money) they offer to trade close to the last price; if they're pessimistic, they still trade, but they quote prices further from the center of the market.

The implementation of this is that market-makers create a bunch of Order instances, all of which get added to the model's orderbook.

The complicated branching structure for market-makers is designed to keep them from accumulating large positions in single stocks; as their position size goes up, they try more aggressively to get it to zero.

Noise traders are more straightforward. As long as they have buying/selling power, they trade. i.e. they pick open orders in the order book, and execute those orders.

What happens when I actually run this is that every time a noise trader executes an order, all noise traders have the position, and all market-makers have the offsetting position

  • 2
    Please try to condense this code down to a [mcve]. You've posted way too much code, especially if the issue is with the initialization of variables. – Bryan Oakley Mar 17 '20 at 16:08
  • 1
    `Trader.__init__` is using a mutable default value for the `positions` parameter; likely duplicate of https://stackoverflow.com/q/1132941/1126841. – chepner Mar 17 '20 at 16:11

0 Answers0