Skip to content

Rendering

sekai environments have no render() method and no render_mode. Rendering is done by a separate renderer object that reads an environment's state and returns a frame. Training code never pays for rendering it does not use, one env can be drawn by several renderers, and recording is a wrapper you add or remove without touching the environment.

The finance environments are drawn as dashboards: the market, the agent's positions and trades, and its results, as the episode unfolds.

Trading-v0

TradingRenderer

Portfolio-v0

PortfolioRenderer

Market-v0

MarketRenderer

MarketMaking-v0

MarketMakingRenderer

OptimalExecution-v0

OptimalExecutionRenderer

LimitOrderBook-v0

LimitOrderBookRenderer

These GIFs were produced by the renderers on this page, with RecordFrames and save_gif(); the finance ones by examples/render_finance.py.

The Renderer protocol

A renderer has two methods:

Method Returns
render(env) an RGB frame (np.ndarray, shape (H, W, 3), uint8) or a text frame (str)
close() releases resources (windows, figures, file handles)

sekai.rendering.Renderer is a runtime-checkable typing.Protocol, so any object with those two methods works (and passes isinstance(obj, Renderer)). Subclass sekai.rendering.BaseRenderer to get a no-op close() and with renderer: support, which calls close() on exit; the built-in renderers do.

Built-in renderers

Classic control

The classic-control environments come with RGB renderers that mirror gymnasium's rgb_array output, drawn with sekai's own Canvas (no pygame needed):

Renderer Draws Frame size
CartPoleRenderer(width=600, height=400) CartPole-v1 (height, width, 3)
MountainCarRenderer(width=600, height=400) MountainCar-v0, MountainCarContinuous-v0 (height, width, 3)
PendulumRenderer(size=500) Pendulum-v1 (size, size, 3)
AcrobotRenderer(size=500) Acrobot-v1 (size, size, 3)

They live in sekai.envs.classic_control, accept a wrapped or unwrapped env, raise ResetRequired before the first reset() and TypeError for the wrong kind of env.

import numpy as np
import sekai
from sekai.envs.classic_control import CartPoleRenderer

env = sekai.make("CartPole-v1")
renderer = CartPoleRenderer()
env.reset(seed=0)
frame = renderer.render(env)
print(frame.shape, frame.dtype)  # (400, 600, 3) uint8

Finance

Every finance environment has a renderer in sekai.envs.finance. Each draws the episode so far as a dashboard: a header with the environment's name, where its prices come from (the date, for dated history) and its headline numbers, a progress rule along the episode, and charts underneath.

Renderer Draws Charts
TradingRenderer Trading-v0 the price, with the lookback window before the episode, the position shaded behind it (long, short) and each trade marked; equity against buying and holding
PortfolioRenderer Portfolio-v0 the portfolio and every asset indexed to 100; the allocation over time; the current weights
MarketRenderer Market-v0 equity, with the simulated market's stressed regime shaded; the universe over time (which slots are tradable and held); the holdings
MarketMakingRenderer MarketMaking-v0 the mid price with the bid and ask quotes and every fill; inventory; profit and loss
OptimalExecutionRenderer OptimalExecution-v0 the shares left against the Almgren–Chriss schedule; shares sold per step; the mid price, execution prices and arrival price
LimitOrderBookRenderer LimitOrderBook-v0 the mid price, spread and the agent's fills; inventory; wealth; the order book as a depth ladder, the agent's orders shown at their place in the queue
LimitOrderBookMarketRenderer LimitOrderBookMarket the same, with every trader in its own colour, over session time

All of them take width=720 and height=450 (text and marks scale with the frame), theme="light" or "dark" (see Themes) and window=None (an int shows only the latest steps, a scrolling chart for long episodes); the two order-book renderers also take levels=8, the price ticks shown on each side of the mid. Like the classic-control renderers they accept a wrapped or unwrapped env and raise TypeError for the wrong kind. They draw at 10 to 20 ms a frame.

from sekai.envs.finance import TradingRenderer
from sekai.rendering import RecordFrames

env = RecordFrames(sekai.make("Trading-v0", episode_length=60), TradingRenderer())
result = env.reset(seed=0)
while not result.done:
    result = env.step(2)  # stay long
print(len(env.frames), env.frames[-1].shape)  # 61 (450, 720, 3)
env.save("trading.gif")

A renderer keeps the history of the episode it draws: every value it shows is recorded as the frames are rendered, so render each step (as RecordFrames does) for complete charts. It recognises a new episode from the env's elapsed_steps going back to 0, and one renderer can draw several environments at once, keeping a history for each. LimitOrderBookMarket is multi-agent, and RecordFrames wraps single-agent environments only, so call renderer.render(env) after each step yourself.

Recording frames

RecordFrames (in sekai.rendering) calls a renderer after every reset() and step() and keeps the frames in env.frames:

from sekai.envs.classic_control import PendulumRenderer
from sekai.rendering import RecordFrames

env = RecordFrames(sekai.make("Pendulum-v1"), PendulumRenderer(size=200))
env.action_space.seed(0)
result = env.reset(seed=0)
while not result.done:
    result = env.step(env.action_space.sample())

print(len(env.frames), env.frames[0].shape)  # 201 (200, 200, 3)
path = env.save("pendulum.gif")              # fps defaults to env.metadata["render_fps"]
print(path)  # pendulum.gif
env.close()                                  # closes the renderer too
Option / method Effect
enabled=True set env.enabled = False to pause recording
max_frames=None stop capturing after this many frames
clear() drop the captured frames, e.g. between episodes
save(path, fps=None) .gif via Pillow, anything else (e.g. .mp4) via imageio

save() needs RGB array frames: frames recorded from a text ("ansi") renderer raise TypeError.

Saving GIFs and videos

save_gif(frames, path, fps=30) and save_video(frames, path, fps=30) write a list of RGB frames of shape (H, W, 3), or one stacked (T, H, W, 3) array; greyscale (H, W) and RGBA (H, W, 4) frames work too. All frames must have the same shape and integer pixel values in [0, 255] (uint8, or any integer dtype within that range), and fps must be positive. Anything else raises before a file is written: ValueError for an empty list, a single frame passed instead of a list, a bad shape or fps, frames of different sizes, float frames (scale an image in [0, 1] with (frame * 255).round().astype(np.uint8)) and values outside [0, 255], and TypeError for text frames. GIFs need Pillow; videos need imageio with its ffmpeg plugin. Both come with pip install "sekai[render]".

The frames of an RGB GIF share one 256-colour palette, so a pixel that does not change keeps its colour from frame to frame and each frame stores only what changed: an animated chart comes out about half the size, colours cannot flicker, and frames with at most 256 colours in all (the classic-control renderers') are saved exactly.

from sekai.rendering import save_gif

env = sekai.make("CartPole-v1")
renderer = CartPoleRenderer(width=300, height=200)
result = env.reset(seed=0)
frames = [renderer.render(env)]
while not result.done:
    result = env.step(1)  # keep pushing right, until the pole falls
    frames.append(renderer.render(env))
print(len(frames), save_gif(frames, "push_right.gif", fps=25))  # 9 push_right.gif
from sekai.rendering import save_video

save_video(frames, "cartpole.mp4", fps=50)   # needs imageio + imageio-ffmpeg

Dashboards for your own environments

The finance renderers are sekai.rendering.Dashboards, assembled from charts that you can use as well, so an environment of your own gets the same kind of renderer in a few lines. A dashboard needs nothing from the environment but readable state:

import numpy as np
import sekai
from sekai.finance.rendering import PriceChart
from sekai.rendering import Dashboard, LineChart, RecordFrames, Series, Stat


class SpreadTrading(sekai.Env):
    """Trade the spread of a cointegrated pair: long below zero, short above."""

    def __init__(self, episode_length=100):
        self.episode_length = episode_length
        self.observation_space = sekai.Box(-np.inf, np.inf, shape=(2,), dtype=np.float32)
        self.action_space = sekai.Discrete(3)  # short, flat, long

    def reset(self, *, seed=None, options=None):
        self._set_rng(seed)
        self.elapsed_steps, self.spread, self.position, self.pnl = 0, 0.0, 0.0, 0.0
        return sekai.ResetResult(self._obs(), {})

    def step(self, action):
        self.position = float(action) - 1.0
        move = -0.15 * self.spread + float(self.rng.normal(0, 0.3))
        self.pnl += self.position * move
        self.spread += move
        self.elapsed_steps += 1
        done = self.elapsed_steps >= self.episode_length
        return sekai.StepResult(self._obs(), self.position * move, False, done, {})

    def _obs(self):
        return np.array([self.spread, self.position], dtype=np.float32)


renderer = Dashboard(
    PriceChart("spread", position="position", reference=0.0, reference_label="Fair value",
               title="Spread", height=3),
    LineChart(Series("pnl", label="P&L", color="accent", fill=True), title="Profit and loss",
              baseline=0.0, height=2),
    title="Spread trading",
    subtitle="A custom environment",
    stats=[Stat("P&L", "pnl", "{:+.2f}"), Stat("Spread", "spread", "{:+.2f}")],
)
env = RecordFrames(SpreadTrading(), renderer)
result = env.reset(seed=0)
while not result.done:
    spread = env.unwrapped.spread
    result = env.step(2 if spread < -0.3 else 0 if spread > 0.3 else 1)
print(len(env.frames), env.frames[-1].shape)  # 101 (450, 720, 3)
env.save("spread_trading.gif")

Sources. Charts read the environment through sources: an attribute path on the unwrapped env ("pnl", "book.best_bid"; a method is not called), or a function of the env as passed to render() (lambda env: env.unwrapped.weights[:-1]). A single attribute the base env lacks is also looked up on its wrappers.

Episodes and the time axis. Each frame records the current value of every source, so a chart shows the episode up to now. The clock is step= (default: the env's elapsed_steps, through wrappers): a clock at 0, or one that goes backwards, starts a new history, and rendering the same step twice redraws it without recording it again. Any number works as a clock (LimitOrderBookMarketRenderer uses session time); with step=None every render counts as a step and you call renderer.reset() between episodes. horizon= fixes the end of the time axis so the episode draws from left to right; by default it is the env's episode_length or n_steps, else the max_episode_steps of its spec, else the axis grows with the episode. window= shows only the latest steps.

Layout. The positional arguments are stacked top to bottom. A list (or Row(...)) puts charts side by side, Column(...) stacks them inside a row, and each chart's width= and height= set its share (or pass heights= and Row(..., widths=...)). Time-series charts share one time axis, labelled under the lowest chart of each column, and reserve a gutter on the right for their y-axis labels so their plots line up.

Header. title= (default: the env's registered id or class name), subtitle= (a string or a source, e.g. the date) and stats=, a list of Stat(label, source, format, delta=...): a headline number, with its change since the episode began when delta="relative" or "absolute" (a direction triangle in the up or down colour; pass higher_is_better=False for costs).

Charts

Chart Shows
LineChart(*series, baseline=, percent=, limits=, shade=) series over time on one axis; Series(source, label=, color=, fill=, signed=, step=, dashed=, rebase=) sets each line's look, rebase=100 indexes it to 100 at the start, shade= a source that shades the steps where it is true
BarChart(source, labels=, colors=, color=, available=, limit=) the current values of a vector as labelled bars (weights, holdings); available= greys out bars that cannot trade
StackedArea(source, labels=, colors=) a vector over time as stacked areas (an allocation's composition)
PriceChart(price, position=, quotes=, spread=, buys=, sells=, context=, reference=) a price with the agent's position shaded behind it, trades as triangles, its quotes, the market's spread band, the history before the episode and a reference level
OrderBookChart(book, owners=, levels=) a sekai.finance.OrderBook as a depth ladder around the mid, with the orders of the owners you name highlighted at their place in each queue
UniverseChart(weights, available=, labels=) a changing universe over time: one row per slot, filled while held, grey while tradable, empty while unlisted

The first three live in sekai.rendering, the market charts in sekai.finance.rendering (and sekai.finance). Colours are theme roles ("accent", "up", "down", "neutral", "highlight", "price", ...), hex strings, RGB tuples or palette slots (0 to 7); left out, the i-th series or bar takes palette slot i, so an asset keeps its colour in every chart.

Custom panels

Subclass sekai.rendering.Panel: sources() names what to record each frame, draw(canvas, box, view) draws it, reading the recorded values back from the View. frame() draws the title (and a legend) and returns the plot area, and sekai.rendering.charts has the pieces the built-in charts use: Plot (data to pixels, gridlines, lines, areas, markers, value tags), nice_ticks, tick_labels, format_number.

from sekai.rendering import Panel
from sekai.rendering.charts import Plot, nice_ticks, tick_labels


class Drawdown(Panel):
    """How far the P&L is below its running peak."""

    def sources(self):
        return {"pnl": "pnl"}  # recorded every frame, read back as view.values("pnl")

    def draw(self, canvas, box, view):
        pnl = view.values("pnl")
        drawdown = pnl - np.maximum.accumulate(pnl)
        area = self.frame(canvas, box, view)  # title row; returns the plot area
        low = min(float(drawdown.min()), -1.0) * 1.1
        plot = Plot(canvas, area, view.x_domain, (low, 0.0), view.theme, view.scale)
        ticks = nice_ticks(low, 0.0, 3)
        plot.grid(ticks, tick_labels(ticks))
        plot.area(view.steps, drawdown, view.theme.down, base=0.0, alpha=0.2)
        plot.line(view.steps, drawdown, view.theme.down)
        self.time_axis(plot, view)


renderer = Dashboard(
    LineChart(Series("pnl", label="P&L", fill=True), title="Profit and loss", baseline=0.0),
    Drawdown("Drawdown"),
    title="Spread trading",
)

A panel with timeseries = False (a class attribute) is not on the time axis, like BarChart and OrderBookChart. context() names values to read once at the start of an episode (PriceChart reads its lookback window this way), and x_start(view) lets a panel start the shared time axis before the first step. A dashboard can also change its charts after it is built, with set_layout(): the Portfolio renderer adds one line per asset when it first sees an environment.

Themes

sekai.rendering.LIGHT (the default) and DARK use the Qian Capital palette: warm paper or a forest-ink surface, with forest-green and gold accents. A Theme names its colours by the job they do: surface and ink (background, grid, axis, text, text_secondary, text_muted), roles (price, accent for the series a chart is about, neutral for benchmarks and cash, highlight for the agent's own orders), the polarity pair up / down (long and short, buys and sells, bids and asks, gains and losses), and series, an eight-colour categorical palette assigned in order and never cycled (a ninth series is drawn in neutral).

Both palettes were checked for colour-vision deficiency: adjacent palette slots stay distinguishable under protanopia and deuteranopia, and so do the first three slots in every pair, as do the up and down colours. Charts never rely on colour alone either: trades are triangles pointing up or down, bids sit below asks, and every chart with two or more series has a legend.

from sekai.rendering import DARK, LIGHT

renderer = TradingRenderer(theme="dark")
blue_up = LIGHT.with_colors(up="#2a78d6", down="#d95926")  # a blue/orange polarity pair
renderer = TradingRenderer(theme=blue_up)

Canvas

sekai.rendering.Canvas is a tiny, dependency-free RGB raster. The origin is at the bottom-left with y pointing up (convenient for physics), and to_array() returns a copy of the (H, W, 3) uint8 image. By default shapes are filled by testing pixel centres; Canvas(width, height, background, antialias=True) draws every shape with exact coverage at its edges instead (smooth lines and curves, rectangles on fractional pixels, round line caps and joins), as the dashboards do.

Method Draws
clear(color=(255, 255, 255)) fills the canvas
rect(left, bottom, right, top, color, radius=0) an axis-aligned rectangle, its corners rounded by radius
polygon(points, color) a filled polygon from (x, y) vertices
circle(cx, cy, radius, color) a filled disc
line(start, end, color, width=1.0, dash=None) a segment; dash=(on, off) in pixels
polyline(points, color, width=1.0, dash=None) connected segments; a NaN point leaves a gap
fill_between(x, y1, y2, color) the area between two curves sampled at increasing x
image(array, left, bottom, right, top) an RGB or RGBA image scaled into a box, each pixel a crisp block (heatmaps, matrices)
text(x, y, text, color, size=12, bold=False, align="left", valign="baseline") a line of text; returns its width

Every shape also takes alpha= (opacity in [0, 1]), and with canvas.clip(left, bottom, right, top): restricts drawing to a rectangle. Text is set in BDO Grotesk, the typeface of these pages, from glyphs rasterised ahead of time at 9 to 32 pixels (other sizes snap to the nearest; larger ones are scaled) in a regular and a bold weight, with kerning and tabular digits, so numbers that change from frame to frame keep their width. The glyphs ship with sekai under the SIL Open Font License; sekai.rendering.text_width(text, size, bold) measures text before you draw it.

sekai.rendering.rotate(points, angle, origin=(0, 0)) rotates points counter-clockwise, for drawing rotated bodies.

from sekai.rendering import Canvas

canvas = Canvas(8, 4)
canvas.rect(0, 0, 4, 2, (0, 0, 0))  # bottom-left 4x2 block
print((canvas.to_array()[:, :, 0] == 0).astype(int))
[[0 0 0 0 0 0 0 0]
 [0 0 0 0 0 0 0 0]
 [1 1 1 1 0 0 0 0]
 [1 1 1 1 0 0 0 0]]

Writing a renderer

A renderer reads whatever public state the env exposes. Two renderers for MarketMaking-v0: an RGB gauge of inventory and session progress, and a text ticker.

from sekai.rendering import Renderer


class InventoryGauge(Renderer):
    """Green/red bar: inventory relative to the limit. Blue strip: session elapsed."""

    def __init__(self, width=300, height=60):
        self.width, self.height = width, height

    def render(self, env):
        mm = env.unwrapped
        canvas = Canvas(self.width, self.height)
        centre = self.width / 2
        end = centre + mm.inventory / mm.max_inventory * centre
        color = (46, 125, 50) if end >= centre else (198, 40, 40)
        canvas.rect(min(centre, end), 20, max(centre, end), 50, color)
        canvas.line((centre, 15), (centre, 55), (0, 0, 0))
        elapsed = 1 - mm.time_remaining / mm.total_time
        canvas.rect(0, 0, elapsed * self.width, 8, (63, 81, 181))
        return canvas.to_array()

    def close(self):
        pass


class Ticker(Renderer):
    def render(self, env):
        mm = env.unwrapped
        return f"t={mm.total_time - mm.time_remaining:.2f} mid={mm.mid:.2f} inv={mm.inventory:+d}"

    def close(self):
        pass


env = RecordFrames(sekai.make("MarketMaking-v0"), InventoryGauge())
ticker = Ticker()
result = env.reset(seed=0)
while not result.done:
    result = env.step(env.unwrapped.avellaneda_stoikov_action())
print(ticker.render(env))  # t=1.00 mid=99.93 inv=-1
print(len(env.frames), env.frames[-1].shape)  # 201 (60, 300, 3)

For a renderer that follows a whole episode, build a dashboard instead: it records the history for you.

With gymnasium

to_gymnasium(env, renderer=...) turns a renderer into a gymnasium render_mode="rgb_array" (pass render_mode="ansi" for a text renderer), so gymnasium's RecordVideo and similar tools work. "human" is not accepted, since a renderer returns frames rather than drawing to a window; wrap the converted env in gymnasium's HumanRendering to watch it. In the other direction, GymnasiumRenderer renders an env brought in with from_gymnasium() by calling the gymnasium env's own render() (for gymnasium's classic-control envs that needs pygame: pip install "gymnasium[classic-control]").