sekai.MultiAgentEnv[ObsType, ActType, AgentID] is a built-in base class for environments
where each agent has its own observation and action space, e.g. a market maker and a
directional trader, or a portfolio manager and a risk manager. Every dictionary in the API is
keyed by agent ID. Agents act simultaneously, or, in an
event-driven env, each on its own clock.
| Member | Notes |
|---|---|
possible_agents |
every agent that can ever take part |
agents |
the agents currently active (may shrink as agents finish) |
observation_spaces, action_spaces |
dict[agent, Space] |
observation_space(agent), action_space(agent) |
lookups into those dicts |
num_agents, max_num_agents |
len(agents), len(possible_agents) |
reset(*, seed=None, options=None) |
abstract; returns MAResetResult(observations, info) |
step(actions) |
abstract; actions is {agent: action}; returns MAStepResult |
async_reset(), async_step() |
default to the sync methods, as for Env |
close(), with env: |
as for Env |
MAStepResult holds observations, rewards, terminated, truncated and info, each a
dict keyed by agent, plus two properties: all_done (every agent is terminated or truncated)
and any_done. They are evaluated per agent ID over the agents in terminated or truncated;
an agent missing from one of the two dicts counts as False there, and with no agents in either
both properties are False. MAResetResult has observations and info, and all_done / any_done
are always False, so one loop handles both, like the single-agent results.
A market maker chooses its half-spread; a trader sees the quotes and decides whether to buy, sell or pass. The trader pays the spread and both mark to the next mid price.
import numpy as np
import sekai
class QuoteAndTradeEnv(sekai.MultiAgentEnv[np.ndarray, object, str]):
def __init__(self, n_steps: int = 50):
self.n_steps = n_steps
self.possible_agents = ["maker", "trader"]
self.agents = list(self.possible_agents)
self.observation_spaces = {
"maker": sekai.Box(-np.inf, np.inf, shape=(2,)), # mid, inventory
"trader": sekai.Box(-np.inf, np.inf, shape=(3,)), # mid, bid, ask
}
self.action_spaces = {
"maker": sekai.Box(0.01, 1.0, shape=(1,)), # half-spread
"trader": sekai.Discrete(3, start=-1), # sell / pass / buy
}
def reset(self, *, seed=None, options=None):
self._set_rng(seed) # same seeding contract as sekai.Env
self.agents = list(self.possible_agents)
self.mid, self.inventory, self.t = 100.0, 0, 0
return sekai.MAResetResult(self._obs(0.1), {a: {} for a in self.agents})
def step(self, actions):
half = float(actions["maker"][0])
side = int(actions["trader"])
next_mid = self.mid + self.rng.normal(0.0, 0.3)
trader_pnl = side * (next_mid - self.mid) - abs(side) * half
self.inventory -= side
maker_pnl = abs(side) * half + self.inventory * (next_mid - self.mid)
self.mid, self.t = next_mid, self.t + 1
done = self.t >= self.n_steps
result = sekai.MAStepResult(
observations=self._obs(half),
rewards={"maker": maker_pnl, "trader": trader_pnl},
terminated={a: done for a in self.agents},
truncated={a: False for a in self.agents},
info={a: {} for a in self.agents},
)
if done:
self.agents = [] # nobody acts after the last step
return result
def _obs(self, half):
return {
"maker": np.array([self.mid, self.inventory], np.float32),
"trader": np.array([self.mid, self.mid - half, self.mid + half], np.float32),
}
Running it with random policies:
env = QuoteAndTradeEnv()
print(env, env.num_agents) # <QuoteAndTradeEnv agents=['maker', 'trader']> 2
print(env.action_space("trader")) # Discrete(3, start=-1)
for agent in env.possible_agents:
env.action_space(agent).seed(0)
result = env.reset(seed=0)
totals = dict.fromkeys(env.agents, 0.0)
while not result.all_done:
actions = {agent: env.action_space(agent).sample() for agent in env.agents}
result = env.step(actions)
for agent, reward in result.rewards.items():
totals[agent] += reward
print({agent: round(total, 2) for agent, total in totals.items()}) # {'maker': 25.88, 'trader': -19.65}
!!! tip “Seeding”
MultiAgentEnv has the same seeding API as sekai.Env: call self._set_rng(seed) at the
start of reset() and draw randomness from self.rng. An int seed re-seeds, None
continues the current stream.
When an agent is done, remove it from self.agents and stop expecting its action. Keep
reporting it in the result dicts on the step it finishes, so learners see its final
transition; all_done becomes True once every agent reported in the result is done. When
the episode ends, self.agents is empty, as in the example above.
In many markets agents do not move in lockstep: a market maker requotes every second, a slower trader decides every minute, an execution algorithm wakes up on fills. sekai’s convention for such environments:
observations holds only the agents that act next; its keys say who moves;step() takes actions from exactly those agents;rewards, terminated, truncated and info cover every agent, so a reward earned
between an agent’s decisions is not lost.A learner adds up each agent’s rewards until that agent’s next observation, which closes one
transition. Steps then have different lengths for different agents; discount by elapsed time
(info[agent]["timestamp"]), as described in
Time and history.
sekai.envs.finance.LimitOrderBookMarket is the built-in example: several traders in one
limit order book, trader i deciding every decision_intervals[i] units of session time
(the finance documentation covers the market itself). With intervals
of 1, 2 and 5, all three act first, then only the fast one:
from sekai.envs.finance import LimitOrderBookMarket
env = LimitOrderBookMarket(3, decision_intervals=[1.0, 2.0, 5.0], session_length=60.0)
for agent in env.possible_agents:
env.action_space(agent).seed(0)
result = env.reset(seed=0)
print(sorted(result.observations)) # ['trader_0', 'trader_1', 'trader_2']
result = env.step({agent: env.action_space(agent).sample() for agent in result.observations})
print(sorted(result.observations), sorted(result.rewards)) # ['trader_0'] ['trader_0', 'trader_1', 'trader_2']
The loop takes its actions from result.observations and its rewards from result.rewards:
result = env.reset(seed=0)
pending = dict.fromkeys(env.possible_agents, 0.0) # reward since each agent's last decision
decisions = dict.fromkeys(env.possible_agents, 0)
while not result.all_done:
actions = {
agent: env.action_space(agent).sample(mask=env.action_mask(agent))
for agent in result.observations # only the agents whose turn it is
}
result = env.step(actions)
for agent, reward in result.rewards.items(): # every agent
pending[agent] += reward
for agent in result.observations: # these agents decide next
# a learner stores (previous observation, action, pending[agent], new observation) here
decisions[agent] += 1
pending[agent] = 0.0
print(decisions) # {'trader_0': 60, 'trader_1': 30, 'trader_2': 12}
On the last step every agent receives a final observation.
The action-mask convention carries over with the agent as an
argument: action_mask(agent) returns that agent’s mask, in the format
action_space(agent).sample(mask=...) accepts, as in the loop above. sekai.get_action_mask()
and ActionMaskObservation are single-agent tools; call the method directly.
print(env.action_space("trader_0")) # MultiDiscrete([6, 6, 3])
print([mask.shape for mask in env.action_mask("trader_0")]) # [(6,), (6,), (3,)]
The wrappers, vector environments and check_env operate on sekai.Env. A MultiAgentEnv
can be registered and built with sekai.make(), but register it without
max_episode_steps, because TimeLimit is a single-agent wrapper:
sekai.register("ma/QuoteAndTrade-v0", QuoteAndTradeEnv)
env = sekai.make("ma/QuoteAndTrade-v0", n_steps=10)
print(env.spec, env.n_steps) # EnvSpec(id='ma/QuoteAndTrade-v0') 10
To train a multi-agent environment with single-agent tooling, expose one agent’s view
through a small sekai.Env adapter that plays the other agents with fixed policies, or use a
multi-agent library through PettingZoo.
sekai.compat.to_pettingzoo(env) exposes a MultiAgentEnv as a PettingZoo ParallelEnv:
spaces become gymnasium spaces and results the usual (observations, rewards, terminations,
truncations, infos) dicts. from_pettingzoo(parallel_env) goes the other way, so any
PettingZoo parallel environment can be driven through the sekai API. Both need the optional
dependency:
pip install "sekai[pettingzoo]"
from pettingzoo.test import parallel_api_test
from sekai.compat import from_pettingzoo, to_pettingzoo
pz_env = to_pettingzoo(QuoteAndTradeEnv())
parallel_api_test(pz_env, num_cycles=100) # passes
observations, infos = pz_env.reset(seed=0)
print(sorted(observations), pz_env.action_space("trader")) # ['maker', 'trader'] Discrete(3, start=-1)
env = from_pettingzoo(pz_env) # any PettingZoo ParallelEnv
print(sorted(env.reset(seed=0).observations), env.action_space("maker")) # ['maker', 'trader'] Box(0.01, 1.0, (1,), float32)
The PettingZoo env keeps its own randomness; seeds passed to reset() go straight to it.
An event-driven env keeps its convention through the bridge: observations only for the
agents that act next, rewards and flags for all. That passes PettingZoo’s parallel API test,
but a consumer that expects an observation for every live agent at every step needs to
handle it.