Writing an agent

This tutorial writes two agents and adds them to the simulation of Running a simulation:

  • MovingAverageAgent, a new strategy written from Agent;

  • ContrarianFCNAgent, a small change to FCNAgent.

The complete script is tutorial_custom_agent.py.

How the runner uses an agent

For each agent of the configuration, the runner:

  1. creates the agent with an agent_id, a name (such as "MovingAverageAgents-0"), the simulator, the logger, and its own random number generator prng. You rarely need to change __init__;

  2. calls setup(settings, accessible_markets_ids) once, after all the markets and agents are created. settings is the agent’s block of the configuration, after extends is resolved and without numAgents, from, to and prefix. All the agents of a group receive the same dict, so read it but do not change it. accessible_markets_ids are the IDs of the markets in the groups listed in the block’s markets;

  3. calls submit_orders(markets) in some of the steps. The agent returns the orders it wants to place or cancel, possibly none;

  4. calls submitted_order, canceled_order and executed_order when 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(...) reads cashAmount and assetVolume, 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. JsonRandom accepts 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.prng for 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, and get_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 raises ValueError: spoofing order is not allowed. please check agent_id in order.

  • A LIMIT_ORDER needs a price, and a MARKET_ORDER must not have one (price=None). volume must be positive.

  • Round limit prices to the tick size with convert_to_tick_level and convert_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. With ttl=n, it is removed once more than n steps have passed since it was placed.

  • To cancel an order, return Cancel(order=...) with the Order you submitted earlier. The market reduces order.volume as the order is executed, so an order without ttl is still in the order book while its volume is positive. An order removed because of its ttl keeps its volume. Canceling an order that is not in the order book any more is not an error.

  • Create a new Order for every order: submitting the same object again raises ValueError: 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_orders if 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 HighFrequencyAgent is a high-frequency agent. The runner asks high-frequency agents after the orders of each normal agent, as set by maxHighFrequencyOrders and highFrequencySubmitRate (see Sessions).

  • MovingAverageAgent keeps its last orders in an attribute set in submit_orders. This works with SequentialRunner and 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.