Writing an agent¶
This tutorial writes two agents and adds them to the simulation of Running a simulation:
MovingAverageAgent, a new strategy written fromAgent;ContrarianFCNAgent, a small change toFCNAgent.
The complete script is tutorial_custom_agent.py.
How the runner uses an agent¶
For each agent of the configuration, the runner:
creates the agent with an
agent_id, aname(such as"MovingAverageAgents-0"), thesimulator, thelogger, and its own random number generatorprng. You rarely need to change__init__;calls
setup(settings, accessible_markets_ids)once, after all the markets and agents are created.settingsis the agent’s block of the configuration, afterextendsis resolved and withoutnumAgents,from,toandprefix. All the agents of a group receive the same dict, so read it but do not change it.accessible_markets_idsare the IDs of the markets in the groups listed in the block’smarkets;calls
submit_orders(markets)in some of the steps. The agent returns the orders it wants to place or cancel, possibly none;calls
submitted_order,canceled_orderandexecuted_orderwhen a market has accepted one of its orders, has accepted one of its cancel orders, or has executed one of its orders.
In each step, the runner asks the agents in a random order until maxNormalOrders of them (1 by default,
see Sessions) have returned at least one order. An agent that returns no order does not count, and
the runner asks the next one. So, in a simulation with many agents, each agent places orders only in a few
steps.
An agent must inherit from Agent and implement submit_orders. The callbacks do nothing
by default.
Reading the parameters¶
MovingAverageAgent compares the market price with the average price of the last windowSize steps.
It reads its parameters in setup:
window_size: int
order_volume: int
last_orders: Dict[int, Order] # market ID -> last order placed
n_submitted: int
n_canceled: int
trades: List[Tuple[int, float, int]] # (step, price, +volume or -volume)
def setup(
self,
settings: Dict[str, Any],
accessible_markets_ids: List[int],
*args: Any,
**kwargs: Any,
) -> None:
"""Read the parameters of the agent from its block of the config.
Args:
settings (Dict[str, Any]): the agent's block of the config. It must include
"windowSize" and can include "orderVolume" (default: 1).
accessible_markets_ids (List[int]): IDs of the markets the agent can trade.
*args: not used.
**kwargs: not used.
"""
super().setup(settings=settings, accessible_markets_ids=accessible_markets_ids)
json_random = JsonRandom(prng=self.prng)
self.window_size = int(json_random.random(settings["windowSize"]))
self.order_volume = int(json_random.random(settings.get("orderVolume", 1)))
self.last_orders = {}
self.n_submitted = 0
self.n_canceled = 0
self.trades = []
super().setup(...)readscashAmountandassetVolume, which every agent requires, and gives the agent its cash and its shares in each accessible market.Any other key of the block can be read from
settings.JsonRandomaccepts the same notation as the built-in agents, such as[10, 30]for a uniform random value (see Random parameters (JsonRandom)), and returns a float.int()truncates it, so[10, 30]gives windows from 10 to 29.Declare the attributes of the agent in the class body and set them in
setup.Use
self.prngfor every random choice of the agent, so that the simulation stays determined by its seed.
Placing orders¶
submit_orders receives every market of the simulation and returns a list of orders:
def submit_orders(self, markets: List[Market]) -> List[Union[Order, Cancel]]:
"""Decide the orders of this step.
Args:
markets (List[Market]): all the markets of the simulation.
Returns:
List[Union[Order, Cancel]]: the orders and cancel orders to submit.
"""
orders: List[Union[Order, Cancel]] = []
for market in markets:
if not self.is_market_accessible(market_id=market.market_id):
continue
time = market.get_time()
if time < self.window_size:
continue
last_order = self.last_orders.pop(market.market_id, None)
if last_order is not None and last_order.volume > 0:
# not fully executed: without ttl, it is still in the order book
orders.append(Cancel(order=last_order))
past_prices = market.get_market_prices(
times=range(time - self.window_size, time)
)
moving_average = sum(past_prices) / len(past_prices)
price = market.get_market_price()
if price > moving_average:
is_buy = True
elif price < moving_average:
is_buy = False
else:
continue
tick_level = market.convert_to_tick_level(price=price, is_buy=is_buy)
order = Order(
agent_id=self.agent_id,
market_id=market.market_id,
is_buy=is_buy,
kind=LIMIT_ORDER,
volume=self.order_volume,
price=market.convert_to_price(tick_level=tick_level),
)
orders.append(order)
self.last_orders[market.market_id] = order
return orders
Skip the markets the agent cannot trade, with
is_market_accessible.Only the current step and the steps before it can be read:
market.get_market_price()is the price now, andget_market_prices(times=...)gives the prices of the earlier steps.Every order needs
agent_id=self.agent_id: an order with another agent’s ID raisesValueError: spoofing order is not allowed. please check agent_id in order.A
LIMIT_ORDERneeds aprice, and aMARKET_ORDERmust not have one (price=None).volumemust be positive.Round limit prices to the tick size with
convert_to_tick_levelandconvert_to_price. Otherwise the market rounds them for you (buy orders down, sell orders up) with a warning.Without
ttl, an order stays in the order book until it is executed or canceled. Withttl=n, it is removed once more thannsteps have passed since it was placed.To cancel an order, return
Cancel(order=...)with theOrderyou submitted earlier. The market reducesorder.volumeas the order is executed, so an order withoutttlis still in the order book while itsvolumeis positive. An order removed because of itsttlkeeps itsvolume. Canceling an order that is not in the order book any more is not an error.Create a new
Orderfor every order: submitting the same object again raisesValueError: the order is already submitted.The orders of one agent are processed in the order of the list, so the cancel above is processed before the new order.
The market does not check cash or shares: an agent can buy without cash and sell shares it does not have. Add such checks to
submit_ordersif your model needs them.
Following the results¶
The callbacks tell the agent what happened to its orders:
def submitted_order(self, log: OrderLog) -> None:
"""Count the orders that a market has accepted.
Args:
log (OrderLog): the accepted order.
"""
self.n_submitted += 1
def executed_order(self, log: ExecutionLog) -> None:
"""Record an execution of one of the agent's orders.
Args:
log (ExecutionLog): the execution.
"""
volume = log.volume if log.buy_agent_id == self.agent_id else -log.volume
self.trades.append((log.time, log.price, volume))
def canceled_order(self, log: CancelLog) -> None:
"""Count the cancel orders that a market has accepted.
Args:
log (CancelLog): the canceled order.
"""
self.n_canceled += 1
executed_order is called for both the buyer and the seller of an execution, after their cash and shares are
updated. There is no callback for an order that expires because of its ttl.
Changing an existing agent¶
To change a built-in agent, inherit from it and override only what differs.
FCNAgent combines a fundamental, a chart and a noise term. Its chart term follows the past
trend when is_chart_following is True, which is the default. ContrarianFCNAgent reverses it:
class ContrarianFCNAgent(FCNAgent):
"""FCN agent whose chart term is reversed: it expects past trends to revert."""
def setup(
self,
settings: Dict[str, Any],
accessible_markets_ids: List[int],
*args: Any,
**kwargs: Any,
) -> None:
"""Set up the FCN agent, then reverse its chart term.
Args:
settings (Dict[str, Any]): the agent's block of the config.
accessible_markets_ids (List[int]): IDs of the markets the agent can trade.
*args: not used.
**kwargs: not used.
"""
super().setup(settings=settings, accessible_markets_ids=accessible_markets_ids)
self.is_chart_following = False
Using the agents¶
The configuration adds a group of each new agent to the one of Running a simulation:
CONFIG: Dict[str, Any] = {
"simulation": {
"markets": ["Market"],
"agents": ["FCNAgents", "ContrarianAgents", "MovingAverageAgents"],
"sessions": [
{
"sessionName": "warmup",
"iterationSteps": 100,
"withOrderPlacement": True,
"withOrderExecution": False,
"withPrint": True,
},
{
"sessionName": "main",
"iterationSteps": 500,
"withOrderPlacement": True,
"withOrderExecution": True,
"withPrint": True,
},
],
},
"Market": {"class": "Market", "tickSize": 0.00001, "marketPrice": 300.0},
"FCNAgents": {
"class": "FCNAgent",
"numAgents": 100,
"markets": ["Market"],
"assetVolume": 50,
"cashAmount": 10000,
"fundamentalWeight": {"expon": [1.0]},
"chartWeight": {"expon": [0.0]},
"noiseWeight": {"expon": [1.0]},
"noiseScale": 0.001,
"timeWindowSize": [100, 200],
"orderMargin": [0.0, 0.1],
},
"ContrarianAgents": {
"extends": "FCNAgents",
"class": "ContrarianFCNAgent",
"numAgents": 20,
"chartWeight": {"expon": [1.0]},
},
"MovingAverageAgents": {
"class": "MovingAverageAgent",
"numAgents": 10,
"markets": ["Market"],
"assetVolume": 50,
"cashAmount": 10000,
"windowSize": [10, 30],
"orderVolume": 1,
},
}
ContrarianAgents copies the block of FCNAgents with extends, then changes its class, its number of
agents and its chartWeight (the chart term does nothing when its weight is 0).
MovingAverageAgents gives the keys that Agent.setup requires, and the keys that MovingAverageAgent
reads.
Register the new classes with the runner before calling main(). The class of a block is the name of the
registered class:
def run_simulation(seed: int) -> SequentialRunner:
"""Register the user-defined agents and run the simulation.
Args:
seed (int): seed of the random number generator.
Returns:
SequentialRunner: the runner after the run.
"""
runner = SequentialRunner(settings=CONFIG, prng=random.Random(seed))
runner.class_register(cls=MovingAverageAgent)
runner.class_register(cls=ContrarianFCNAgent)
runner.main()
return runner
def report(runner: SequentialRunner) -> None:
"""Print what each moving average agent did.
Args:
runner (SequentialRunner): the runner after the run.
"""
market = runner.simulator.name2market["Market"]
for agent in runner.simulator.agents_group_name2agent["MovingAverageAgents"]:
if isinstance(agent, MovingAverageAgent):
print(
f"{agent.name}: window {agent.window_size}, "
f"orders {agent.n_submitted}, cancels {agent.n_canceled}, "
f"trades {len(agent.trades)}, "
f"shares {agent.get_asset_volume(market_id=market.market_id)}, "
f"cash {agent.get_cash_amount():.2f}"
)
report(run_simulation(seed=42)) prints the two time lines of main(), then:
MovingAverageAgents-0: window 12, orders 2, cancels 0, trades 2, shares 50, cash 9989.21
MovingAverageAgents-1: window 27, orders 1, cancels 0, trades 1, shares 51, cash 9697.14
MovingAverageAgents-2: window 14, orders 4, cancels 0, trades 4, shares 52, cash 9394.82
MovingAverageAgents-3: window 13, orders 1, cancels 0, trades 1, shares 49, cash 10295.09
MovingAverageAgents-4: window 11, orders 4, cancels 0, trades 4, shares 48, cash 10588.81
MovingAverageAgents-5: window 12, orders 4, cancels 1, trades 3, shares 51, cash 9685.83
MovingAverageAgents-6: window 12, orders 2, cancels 0, trades 2, shares 50, cash 9994.57
MovingAverageAgents-7: window 10, orders 6, cancels 0, trades 6, shares 46, cash 11195.17
MovingAverageAgents-8: window 22, orders 5, cancels 2, trades 3, shares 49, cash 10293.29
MovingAverageAgents-9: window 12, orders 5, cancels 0, trades 5, shares 51, cash 9684.50
The moving average agents place no order in warmup: the market price does not move while orders are not
executed, so it equals its average. In main, only one agent places orders in each step, as explained above,
and the agents share these steps with the 120 FCN agents. To make them trade more, raise maxNormalOrders in
the sessions, or add more of them.
Class names must be unique: do not name your class like a built-in class, such as FCNAgent.
User-defined classes gives more details.
Notes¶
An agent that inherits from
HighFrequencyAgentis a high-frequency agent. The runner asks high-frequency agents after the orders of each normal agent, as set bymaxHighFrequencyOrdersandhighFrequencySubmitRate(see Sessions).MovingAverageAgentkeeps its last orders in an attribute set insubmit_orders. This works withSequentialRunnerand the thread runner, but not with the process runner, which loses such changes (see Parallel runners).
Next, Writing an event or a market changes the market during the simulation, and Writing a logger records the trades.