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