Last updated on 16th July 2024
In this introductory tutorial, you will learn to make a simple agent-based model of traders trading in a stylised financial market such as a stock market.
The model is comprised of traders and a common information signal observed by all traders, which we can think of as a 'news arrival' process. All traders compare this information signal to an internal trading threshold.
Traders place a buy order when they believe the trading signal is positive and a sell order when they believe the signal is negative.
The net volume of buy and sell orders is mapped through a function that generates a change in the asset price (e.g. stock price).
Realistic price dynamics (e.g. stylised facts) emerge from the model based on simple behavioural rules and the interaction of heterogeneous agents.
This model is defined by the following classes:
The AgentBasedModel Class contains useful functionality to help build agent-based models. The TradingModel Class extends AgentBasedModelsetup()
and step()
methods as part of the TradingModel class.
setup()
method sets up the simulation before it is run.step()
method is called at each iteration or "step" in the agent-based model simulation. For example, a step might represent one time increment or day of trading activity.import simudyne.core.Model;
public class TradingModel extends AgentBasedModel<GlobalState> {
@Override
public void setup() {
super.setup();
}
@Override
public void step() {
super.step();
}
}
We build the trading model as an agent-based-model, with two agents, Trader and Market.
Each agent needs to be created as a class which extends simudyne.core.abm.Agent
. We also need to specify the global variable available to the agent (by default simudyne.core.abm.GlobalState
), which we will discuss extending later on.
Trader.java
import simudyne.core.abm.Agent;
import simudyne.core.abm.GlobalState;
public class Trader extends Agent<GlobalState> {}
Market.java
import simudyne.core.abm.Agent;
import simudyne.core.abm.GlobalState;
public class Market extends Agent<GlobalState> {}
We create groups of the agents, which registers the agents in the model. For this example, we create a group of 1000 Trader agents, and a group with a single Market agent. Create the groups inside the setup
method, so they are created once, when the model is setup.
The Trader agents and Market agents are all connected to each other, so they can send messages to each other. Read more about connecting agents here.
We then call super.setup() to setup the AgentSystem.
We then create Accumulators
which we will use to keep track of the Traders buys, sells and market price across the model. By default, accumulators are meant to aggregate per-step statistics and are reset at each step. To override this behavior, call myAccumulator.setPersistent(true)
in the setup()
method. Accumulators take two parameters. A String
for the accumulator name which will be used to access the accumulator, and an optional String
for the accumulator display name - this is the name that will display in the console when showing the value of the accumulator.
We call super.step
inside the step method to reset the accumulators at the beginning of every step.
TradingModel.java
import simudyne.core.abm.AgentBasedModel;
import simudyne.core.abm.GlobalState;
import simudyne.core.abm.Group;
import simudyne.core.annotations.ModelSettings;
import simudyne.core.annotations.Variable;
@ModelSettings(macroStep = 100)
public class TradingModel extends AgentBasedModel<GlobalState> {
{
createLongAccumulator("buys", "Number of buy orders");
createLongAccumulator("sells", "Number of sell orders");
createDoubleAccumulator("price", "Price");
}
@Override
public void setup() {
Group<Trader> traderGroup = generateGroup(Trader.class, 1000);
Group<Market> marketGroup = generateGroup(Market.class, 1);
traderGroup.fullyConnected(marketGroup, Links.TraderMktLink.class);
marketGroup.fullyConnected(traderGroup, Links.MktTraderLink.class);
super.setup();
}
@Override
public void step() {
super.step();
run(
Trader.processInformation(),
Market.calcPriceImpact(),
Trader.updateThreshold()
);
}
}
In the step method of the model, we define the sequence of actions that takes place within a step. Firstly, the traders process the information signal - and decide whether to place buy or sell orders. Then the market calculates the price impact of the buy and sell orders. Finally the traders update their trading thresholds.
Don't worry if the model doesn't compile at this point. We start with defining what methods we will need to make the model building process easier. Once we know which methods we require, we can go and create them.
TradingModel.java
@Override
public void step() {
super.step();
run(
Trader.processInformation(),
Market.calcPriceImpact(),
Trader.updateThreshold());
}
We now create methods in the Trader and Market classes for each of these actions in the sequence. Sequences
are a list of Actions
, so every method will have to return an Action
, see Actions and Sequencing. We create the Actions inside the Agent classes rather than in the sequence directly in order to make the sequence easy to read, more information on class organisation can be found here.
Trader.java
import simudyne.core.abm.Action;
import simudyne.core.abm.Agent;
import simudyne.core.abm.GlobalState;
public class Trader extends Agent<GlobalState> {
public static Action<Trader> processInformation() {
return Action.create(Trader.class, trader -> {});
}
public static Action<Trader> updateThreshold() {
return Action.create(Trader.class, trader -> {});
}
}
Market.java
import simudyne.core.abm.Action;
import simudyne.core.abm.Agent;
import simudyne.core.abm.GlobalState;
public class Market extends Agent<GlobalState> {
public static Action<Market> calcPriceImpact() {
return Action.create(Market.class, market -> {});
}
}
All actions are triggered by messages, agents will only complete the actions in the sequence if they have received a message. Therefore, at every action, we need to send a message to the next agent in the sequence. For more information on messaging, see Messaging Semantics.
When we send messages, the body of the messages to send are also represented as classes. We create a shared message class to declare all message classes as static classes in one place.
We start by creating message classes for two messages being sent in the first action of the sequence, BuyOrderPlaced
, and SellOrderPlaced
.
Messages.java
import simudyne.core.graph.Message;
public class Messages {
public static class BuyOrderPlaced extends Message.Empty{ }
public static class SellOrderPlaced extends Message.Empty{ }
}
Messages sent are done so along a specific link or to a particular agent. In our case, traders will send messages along a trading link class. We create a shared link class all traders can use in a similar ilk to the message classes.
import simudyne.core.graph.Link;
public class Links {
public static class TraderMktLink extends Link.Empty {}
public static class MktTraderLink extends Link.Empty {}
}
More information regarding the new messaging API can be found here.
The first method called in the sequence is Trader.processInformation()
. We can see below that the trader compares the informationSignal to their tradingThresh. Based on the relative magnitudes of the two variables, they may place a buy or sell order - or do nothing. The traders also send a message
, along their TradeLinks, to let the market know that they have placed their orders. The message is sent to all agents connected via this link type, which in this case is the market trader.
We will discuss in Globals how to pass the information signal through the model. Until then, we will set the information signal to a random number for each Trader.
Trader.java
import simudyne.core.abm.Action;
import simudyne.core.abm.Agent;
import simudyne.core.annotations.Variable;
import java.util.Random;
public class Trader extends Agent<TradingModel.Globals> {
static Random random = new Random();
@Variable double tradingThresh = random.nextGaussian();
public static Action<Trader> processInformation() {
return Action.create(
Trader.class,
trader -> {
double informationSignal = random.nextGaussian() * 0.01;
if (Math.abs(informationSignal) > trader.tradingThresh) {
if (informationSignal > 0) {
trader.buy();
} else {
trader.sell();
}
}
});
}
private void buy() {
getLongAccumulator("buys").add(1);
getLinks(Links.TraderMktLink.class)
.send(Messages.BuyOrderPlaced.class);
}
private void sell() {
getLongAccumulator("sells").add(1);
getLinks(Links.TraderMktLink.class)
.send(Messages.SellOrderPlaced.class);
}
public static Action<Trader> updateThreshold() {
return Action.create(Trader.class, trader -> {});
}
}
The next action in the sequence of events is performed by the market. The method Market.calcPriceImpact()
is called. The market starts by checking that it has received a message from the traders confirming that orders have been placed. It then calculates the netDemand for the asset (buys - sells) and uses this value to compute a priceChange. It also updates the price of the asset.
As with the information signal, the number of traders and a lambda value will be passed as a global value. For now, we get the number of traders by assuming that all connected agents to the market are traders, and getting the number of connected agents using getLink()
. We set the value of lambda to 10 for now.
The market computes the price change based on the net demand generated by the traders. The Market sets the new price using the accumulator, and sends the price change as a message to all connected traders so that as in the code snippet under the Trader Behaviour (II) section, the traders may update their trading threshold to equal the previously observed price change.
Market.java
import simudyne.core.abm.Action;
import simudyne.core.abm.Agent;
public class Market extends Agent<TradingModel.Globals> {
private static double price = 4.0;
public static Action<Market> calcPriceImpact() {
return Action.create(Market.class, market -> {
int buys = market.getMessagesOfType(Messages.BuyOrderPlaced.class).size();
int sells = market.getMessagesOfType(Messages.SellOrderPlaced.class).size();
int netDemand = buys - sells;
if (netDemand == 0) {
market.getLinks(Links.MktTraderLink.class)
.send(Messages.PriceChange.class, 0.0);
} else {
long nbTraders = market.getLinks().size();
double lambda = 10;
double priceChange = (netDemand / (double) nbTraders) / lambda;
price += priceChange;
market.getDoubleAccumulator("price").add(price);
market.getLinks(Links.MktTraderLink.class)
.send(Messages.PriceChange.class, priceChange);
}
});
}
}
The final action in the sequence is where traders update their trading threshold. This introduces agent heterogeneity into the model - a key feature of agent based models and the source of the interesting pricing dynamic in this model.
Trader.java extended Behaviour
static Action<Trader> updateThreshold() {
return Action.create(
Trader.class,
trader -> {
double updateFrequency = trader.getGlobals().updateFrequency;
if (random.nextDouble() <= updateFrequency) {
trader.tradingThresh =
trader.getMessageOfType(Messages.PriceChange.class).getBody();
}
});
}
A Globals class that extends GlobalState is created to store values that will be accessible throughout the model.
For this model, there are several values that need to be accessible throughout the model. We create a globals class inside the Model class, with fields for each of the following values.
The Model needs to now extend AgentBasedModel of type globals.
TradingModel.java
import simudyne.core.abm.AgentBasedModel;
import simudyne.core.abm.GlobalState;
import simudyne.core.abm.Group;
import simudyne.core.graph.DoubleAccumulator;
import simudyne.core.graph.LongAccumulator;
import java.util.Random;
public class TradingModel extends AgentBasedModel<TradingModel.Globals> {
public static final class Globals extends GlobalState {
public double updateFrequency = 0.01;
public long nbTraders = 1000;
public double lambda = 10;
public double volatilityInfo = 0.001;
public double informationSignal = new Random().nextGaussian() * volatilityInfo;
}
...
}
We need to change the type of all our agents to the new Globals object, instead of the default GlobalState.
public class Trader extends Agent<TradingModel.Globals> {...}
public class Market extends Agent<TradingModel.Globals> {...}
We change the Model setup to use the number of traders from the globals state when creating the Traders group.
TradingModel.java
@Override
public void setup() {
Group<Trader> traderGroup = generateGroup(Trader.class, getGlobals().nbTraders);
Group<Market> marketGroup = generateGroup(Market.class, 1);
traderGroup.fullyConnected(marketGroup, Links.TraderMktLink.class);
marketGroup.fullyConnected(traderGroup, Links.MktTraderLink.class);
super.setup();
}
We update the Trader to use the update frequency and information signal from the global state.
Trader.java
public class Trader extends Agent<TradingModel.Globals> {
static Random random = new Random();
@Variable double tradingThresh = random.nextGaussian();
public static Action<Trader> processInformation() {
return Action.create(
Trader.class,
trader -> {
double informationSignal = trader.getGlobals().informationSignal;
if (Math.abs(informationSignal) > trader.tradingThresh) {
if (informationSignal > 0) {
trader.buy();
} else {
trader.sell();
}
}
});
}
public static Action<Trader> updateThreshold() {
return Action.create(
Trader.class,
trader -> {
double updateFrequency = trader.getGlobals().updateFrequency;
if (random.nextDouble() <= updateFrequency) {
trader.tradingThresh =
trader.getMessageOfType(Messages.PriceChange.class).getBody();
}
});
}
...
}
... and update the Market to use the number of traders and value of lambda from the globals.
Market.java
public class Market extends Agent<TradingModel.Globals> {
@Variable
public double price;
public static Action<Market> calcPriceImpact() {
return Action.create(Market.class, market -> {
int buys = market.getMessagesOfType(Messages.BuyOrderPlaced.class).size();
int sells = market.getMessagesOfType(Messages.SellOrderPlaced.class).size();
int netDemand = buys - sells;
if (netDemand == 0) {
market.getLinks(Links.MktTraderLink.class)
.send(Messages.PriceChange.class, 0.0);
} else {
long nbTraders = market.getGlobals().nbTraders;
double lambda = market.getGlobals().lambda;
double priceChange = (netDemand / (double) nbTraders) / lambda;
market.price += priceChange;
market.getDoubleAccumulator("price").add(market.price);
market.getLinks(Links.MktTraderLink.class)
.send(Messages.PriceChange.class, priceChange);
}
});
}
}
We recalculate the value of the information signal every step.
@Override
public void step() {
getGlobals().informationSignal = new Random().nextGaussian() *
getGlobals().volatilityInfo;
...
}
The next step is adding annotations to the model to connect parts of it with the console(Annotations).
We use the @Input annotation to mark a field as an input field that the user will be able to change at any point in time in the model, and the @Constant annotation for fields that can only be changed before the model is setup.
We give our annotations user friendly names that will be used in the console
TradingModel.java
@ModelSettings(macroStep = 100)
public class TradingModel extends AgentBasedModel<TradingModel.Globals> {
public static final class Globals extends GlobalState {
@Input(name = "Update Frequency")
public double updateFrequency = 0.01;
@Constant(name = "Number of Traders")
public long nbTraders = 1000;
@Input(name = "Lambda")
public double lambda = 10;
@Input(name = "Volatility of Information Signal")
public double volatilityInfo = 0.001;
public double informationSignal = new Random().nextGaussian() * volatilityInfo;
}
...
}
Model outputs are annotated as @Variable. Accumulators will be automatically reported so don't need to be annotated as @Variable
The model output is displayed on the console. Here we have run the model for 800 ticks - with each tick representing one trading day. We can observe that the price walk looks like a real financial time-series. Further statistical analysis of the properties of these data would reveal that the prices generated behave like those generated in real financial markets.
Running a simulation just once is not sufficiently robust to produce estimates of the likely path of key output variables. Instead we perform a Monte Carlo analysis - that is we run each simulation a large number of times to generate distributions of outcomes - which we can visualise as a fan chart. In the image below, I run the model out 1000 ticks, 20 times. This produces a distribution of price outcomes - illustrating the uncertainty inherent in the price walk generated by the time series.
Below you can see the final code to reproduce.
TradingModel.java
package sandbox.models.trading;
import simudyne.core.abm.AgentBasedModel;
import simudyne.core.abm.GlobalState;
import simudyne.core.abm.Group;
import simudyne.core.annotations.Constant;
import simudyne.core.annotations.Input;
import simudyne.core.annotations.ModelSettings;
import simudyne.core.annotations.Variable;
import simudyne.core.graph.DoubleAccumulator;
import simudyne.core.graph.LongAccumulator;
import simudyne.core.rng.SeededRandom;
@ModelSettings(macroStep = 100)
public class TradingModel extends AgentBasedModel<TradingModel.Globals> {
public SeededRandom rng = SeededRandom.create();
public static final class Globals extends GlobalState {
@Input(name = "Update Frequency")
public double updateFrequency = 0.01;
@Constant(name = "Number of Traders")
public long nbTraders = 1000;
@Input(name = "Lambda")
public double lambda = 10;
@Input(name = "Volatility of Information Signal")
public double volatilityInfo = 0.001;
public double informationSignal;
}
@Variable(name = "Number of buy orders")
public LongAccumulator buys = createLongAccumulator("buys");
@Variable(name = "Number of sell orders")
public LongAccumulator sells = createLongAccumulator("sells");
@Variable(name = "Price")
public DoubleAccumulator priceAccumulator = createDoubleAccumulator("price");
@Override
public void setup() {
getGlobals().informationSignal = rng.gaussian(0.0, getGlobals().volatilityInfo).sample();
Group<Trader> traderGroup =
generateGroup(Trader.class, getGlobals().nbTraders);
Group<Market> marketGroup =
generateGroup(Market.class, 1, market -> {
market.price = 4.0;
});
traderGroup.fullyConnected(marketGroup, Links.TraderMktLink.class);
marketGroup.fullyConnected(traderGroup, Links.MktTraderLink.class);
super.setup();
}
@Override
public void step() {
super.step();
getGlobals().informationSignal = rng.gaussian(0.0, getGlobals().volatilityInfo).sample();
run(
Trader.processInformation(),
Market.calcPriceImpact(),
Trader.updateThreshold()
);
}
}
Trader.java
package sandbox.models.trading;
import simudyne.core.abm.Action;
import simudyne.core.abm.Agent;
import simudyne.core.annotations.Variable;
import java.util.Random;
public class Trader extends Agent<TradingModel.Globals> {
static Random random = new Random();
@Variable double tradingThresh = random.nextGaussian();
public static Action<Trader> processInformation() {
return Action.create(
Trader.class,
trader -> {
double informationSignal = trader.getGlobals().informationSignal;
if (Math.abs(informationSignal) > trader.tradingThresh) {
if (informationSignal > 0) {
trader.buy();
} else {
trader.sell();
}
}
});
}
static Action<Trader> updateThreshold() {
return Action.create(
Trader.class,
trader -> {
double updateFrequency = trader.getGlobals().updateFrequency;
if (random.nextDouble() <= updateFrequency) {
trader.tradingThresh =
trader.getMessageOfType(Messages.PriceChange.class).getBody();
}
});
}
private void buy() {
getLongAccumulator("buys").add(1);
getLinks(Links.TraderMktLink.class).send(Messages.BuyOrderPlaced.class);
}
private void sell() {
getLongAccumulator("sells").add(1);
getLinks(Links.TraderMktLink.class).send(Messages.SellOrderPlaced.class);
}
}
Market.java
package sandbox.models.trading;
import simudyne.core.abm.Action;
import simudyne.core.abm.Agent;
import simudyne.core.annotations.Variable;
public class Market extends Agent<TradingModel.Globals> {
@Variable
public double price;
public static Action<Market> calcPriceImpact() {
return Action.create(Market.class, market -> {
int buys = market.getMessagesOfType(Messages.BuyOrderPlaced.class).size();
int sells = market.getMessagesOfType(Messages.SellOrderPlaced.class).size();
int netDemand = buys - sells;
if (netDemand == 0) {
market.getLinks(Links.MktTraderLink.class)
.send(Messages.PriceChange.class, 0.0);
} else {
long nbTraders = market.getGlobals().nbTraders;
double lambda = market.getGlobals().lambda;
double priceChange = (netDemand / (double) nbTraders) / lambda;
market.price += priceChange;
market.getDoubleAccumulator("price").add(market.price);
market.getLinks(Links.MktTraderLink.class)
.send(Messages.PriceChange.class, priceChange);
}
});
}
}
Messages.java
import simudyne.core.graph.Message;
public class Messages {
public static class BuyOrderPlaced extends Message.Empty {}
public static class SellOrderPlaced extends Message.Empty {}
public static class PriceChange extends Message.Double {}
}
\
Links.java
import simudyne.core.graph.Link;
public class Links {
public static class TraderMktLink extends Link.Empty {}
public static class MktTraderLink extends Link.Empty {}
}