Writing a Strategy
A Tektii strategy is a plain Python program built on the Tektii Python SDK. It connects to a Trading Gateway, consumes a stream of market events, and submits orders in response. The same code runs unchanged in a backtest and against a live broker — the SDK handles the difference internally, so there is no mode flag in your strategy.
This page covers the strategy shape: the event loop, the callbacks, order submission, and position sizing. The complete working example is the ma_crossover template — the same image the platform provisions when you create a version with --template ma-crossover.
Install the SDK
pip install tektii
The SDK exposes two clients: AsyncTradingGateway (asyncio, recommended) and a synchronous TradingGateway. Both read the gateway address and API key from the environment, so the same binary points at a local gateway or the platform with no code change:
| Env var | Default | Purpose |
|---|---|---|
TRADING_GATEWAY_URL | http://localhost:8080 | Gateway base URL |
TRADING_GATEWAY_API_KEY | (unset) | API key for remote gateways |
Both also have TEKTII_-prefixed variants (TEKTII_TRADING_GATEWAY_URL, TEKTII_TRADING_GATEWAY_API_KEY) that take precedence over the unprefixed names when both are set.
The strategy shape
The canonical shape is a stateful class plus an event loop. You open the client, enter the stream, and dispatch each event to a handler:
import asyncio
from tektii import (
AsyncTradingGateway,
CandleEvent,
ConnectionEvent,
ErrorEvent,
OrderEvent,
)
class MyStrategy:
def __init__(self, gw: AsyncTradingGateway) -> None:
self._gw = gw
async def on_candle(self, event: CandleEvent) -> None:
bar = event.bar # symbol, open, high, low, close, volume, timestamp
... # indicator updates + entry/exit decisions go here
def on_order(self, event: OrderEvent) -> None:
... # fills, cancellations, rejects
async def run(self) -> None:
async with self._gw.stream() as events:
async for event in events:
match event:
case CandleEvent():
await self.on_candle(event)
case OrderEvent():
self.on_order(event)
case ConnectionEvent(event=ev, broker=broker, error=err):
print(f"connection {ev} broker={broker} error={err}")
case ErrorEvent(code=code, message=msg):
print(f"gateway error {code}: {msg}")
async def main() -> None:
async with AsyncTradingGateway() as gw:
await MyStrategy(gw).run()
if __name__ == "__main__":
asyncio.run(main())
The events you receive are the ones the run subscribes to. In a backtest, the scenario configuration's subscriptions decide which instrument and candle timeframe stream into your strategy — see the scenario configuration reference.
Key callbacks
| Event | Handler convention | Fires when |
|---|---|---|
CandleEvent | on_candle | A bar closes on a subscribed timeframe. event.bar carries OHLCV + symbol. |
OrderEvent | on_order | An order changes state — submitted, filled, cancelled, rejected. |
ConnectionEvent | inline | The gateway's broker connection opens, closes, or errors. |
ErrorEvent | inline | The gateway reports an error outside any one order. |
The instrument is not configured in your code — it arrives on the stream. Read event.bar.symbol off the first candle and trade that. One uploaded image then runs against every instrument and timeframe you point a scenario at, with no rebuild. Both reference templates work this way.
Submitting orders
submit_order places an order and returns an OrderHandle (id + status). The minimal call is symbol, side, and quantity — a market order:
handle = await gw.submit_order(
symbol=bar.symbol,
side="buy", # or "sell"
quantity=qty, # Decimal or string — a fixed instrument amount
)
side is case-insensitive — "buy" / "sell" and "BUY" / "SELL" are equivalent. If you prefer the typed enum, from tektii import Side and pass Side.BUY / Side.SELL; its members carry the upper-case values BUY and SELL.
The most common optional parameters are order_type ("market", "limit", "stop", "stop_limit", "trailing_stop"), limit_price, stop_price, time_in_force, and bracket legs; the SDK exposes more — trailing distances, OCO groups, reduce-only, leverage — for brokers that support them:
handle = await gw.submit_order(
symbol=bar.symbol,
side="buy",
quantity=qty,
stop_loss=sl_price, # attach a stop-loss to the entry
take_profit=tp_price, # attach a take-profit to the entry
)
A rejected order raises OrderRejectedError — catch it and decide whether to retry, resize, or stay flat:
from tektii import OrderRejectedError
try:
handle = await gw.submit_order(symbol=bar.symbol, side="buy", quantity=qty)
except OrderRejectedError as err:
log.warning("order rejected code=%s message=%s", err.code, err.message)
return # stay flat
Position sizing — quantity_for_notional
An order quantity is a fixed instrument amount, not a share of capital — 0.01 means 0.01 BTC regardless of your account size. On the default ~100k starting capital that is a near-zero position and produces a flat, meaningless backtest. Size your orders with the SDK helper instead:
# Target a share of account equity (10% here):
qty = await gw.quantity_for_notional(bar.symbol, equity_fraction="0.10")
# Or a fixed cash amount in the account currency:
qty = await gw.quantity_for_notional(bar.symbol, notional="5000")
# Pass the bar close as the reference price to skip a separate quote request:
qty = await gw.quantity_for_notional(
bar.symbol, equity_fraction="0.10", price=bar.close
)
Provide exactly one of notional or equity_fraction. The helper reads account equity (for equity_fraction) and divides by the reference price — the quote midpoint by default, or the price you pass. It returns a full-precision Decimal ready for submit_order; round it to the venue's lot size when trading a real broker.
Warming up from history
Indicators need a window of bars before they produce a signal. Rather than discarding the first N live bars, back-fill from history with get_bars:
bars = await gw.get_bars(symbol, "1m", limit=25)
for bar in bars:
update_indicators(bar.close)
The ma_crossover template runs this lazily on the first candle — the strategy is symbol-agnostic, so it only learns which instrument to back-fill once the stream delivers the first bar.
The complete example: ma_crossover
The ma_crossover template is the canonical reference strategy: a trend-follower that buys when a short SMA crosses above a long SMA (golden cross) and exits on the reverse (death cross). One position at a time; market-order entries with optional bracket stop-loss / take-profit.
It demonstrates every pattern on this page in ~450 lines:
- A frozen
Configdataclass parsed from environment variables, validated at startup - A small state machine (
FLAT/LONG) that survives partial fills and broker rejects - Pure, unit-testable indicator helpers
- Lazy history warm-up via
get_bars - Equity-fraction sizing via
quantity_for_notional - Bracket orders and
OrderRejectedErrorhandling - Clean
SIGTERMshutdown
It also ships a full unit-test suite (test_strategy.py) — the reference for testing the pure indicator and state-machine logic without a gateway.
Its sibling, rsi_momentum, applies the same shape to a mean-reversion RSI strategy.
Both templates are built into the platform. Create a runnable version directly from one — no Docker build required:
tektii version upload a1b2c3d4-e5f6-4a1b-9c2d-3e4f5a6b7c8d --template ma-crossover
Then configure and run a backtest against it as in the write workflow.
Template environment variables
The template reads all of its parameters from environment variables:
| Name | Default | Description |
|---|---|---|
ORDER_EQUITY_FRACTION | 0.10 | Position size as a fraction of account equity (0.10 = 10%), sized at the signal-bar price on each entry |
MA_SHORT | 10 | Short SMA period, in bars |
MA_LONG | 20 | Long SMA period, in bars |
TIMEFRAME | 1m | Bar resolution for the warm-up backfill; match it to the run's subscription timeframe |
STOP_LOSS_PCT | (unset) | e.g. 0.02 attaches a 2% stop-loss to entries |
TAKE_PROFIT_PCT | (unset) | e.g. 0.04 attaches a 4% take-profit to entries |
LOG_LEVEL | INFO | Python logging level |
TRADING_GATEWAY_URL | http://localhost:8080 | Gateway base URL (read by the SDK) |
TRADING_GATEWAY_API_KEY | (unset) | API key for remote gateways (read by the SDK) |
Bake your strategy's default parameters into the image with ENV in your Dockerfile:
ENV MA_SHORT=15 ENV MA_LONG=50 ENV STOP_LOSS_PCT=0.02
These are the defaults — they're no longer the only way to set a parameter. Each scenario config and each run takes a per-run env map that layers on top of the baked ENV at launch, so you can sweep MA_SHORT / MA_LONG / STOP_LOSS_PCT across runs against one image, with no rebuild. See per-run env overrides in the write workflow. (The instrument is not a parameter — it comes from the run's subscriptions, see the agnostic-strategy note above.)
Run it locally
You can develop against a local gateway with the mock provider — no broker credentials and no platform account needed:
docker run -e GATEWAY_PROVIDER=mock -p 8080:8080 ghcr.io/tektii/gateway:latest
Then run your strategy directly:
python strategy.py
The SDK's defaults point at http://localhost:8080, so no configuration is required for the local loop.
Next steps
- Write Workflow — package your strategy in Docker, upload it as a version, and run a backtest
- Available Instruments — the symbols and date ranges you can subscribe to
- Read Workflow — pull the equity curve, trades, and metrics after a run