Tektii just went live. We're shipping fixes daily — spot a bug? Let us know

Position Modes

Every scenario runs in one of two position modes, and the choice decides what list_positions() gives your strategy back: one blended position per symbol, or one position per fill.

Netting (default)Hedging
Positions returnedFills on a symbol collapse into one position per symbolOne position per open fill
Position idSynthetic, pos-<symbol> (e.g. pos-F:EURUSD)The trade id — the id of the order that opened it
average_entry_priceWeighted average across all fillsThat single fill's entry price
unrealized_pnlAggregate for the symbolThat leg's own P&L
Long and short at onceNot possible — they net offBoth can be open on the same symbol
MarginNettedSum of all legs (no hedge benefit)

Choosing a mode

Netting suits strategies that hold one directional view per instrument — a trend follower that is long, flat, or short, like the ma_crossover template. One position per symbol is all the bookkeeping you need.

Hedging suits strategies whose entries are individually meaningful: grids, scale-ins, ladders, and anything that wants a long and a short open on the same symbol at once. Under hedging, each fill becomes its own independent position with its own id and its own P&L, so per-leg logic reads straight off list_positions() instead of requiring a shadow ledger of which chunk of a net position came from which entry.

Enabling a mode

Position mode is a property of the scenario, not of your strategy code — the same uploaded image runs under either. In a CLI config file the key is camelCase:

{
  "positionMode": "hedging"
}

Over MCP and the REST API the same field is snake_case, position_mode. Both accept netting and hedging, and both default to netting when omitted. Full field list in the scenario configuration reference.

What your strategy sees

Under hedging, list_positions() returns one Position per open fill. A three-leg scale-in on EUR/USD, after price has moved back up through the last entry:

[
  {
    "id": "b1f0c3a2-5d47-4e91-9c8a-2f6e0d13ab77",
    "symbol": "F:EURUSD",
    "side": "LONG",
    "quantity": "4608.3",
    "average_entry_price": "1.08500",
    "current_price": "1.08200",
    "unrealized_pnl": "-13.82"
  },
  {
    "id": "7c93e28d-40b1-4a6f-bb02-19d5c8e4f350",
    "symbol": "F:EURUSD",
    "side": "LONG",
    "quantity": "4616.8",
    "average_entry_price": "1.08300",
    "current_price": "1.08200",
    "unrealized_pnl": "-4.62"
  },
  {
    "id": "e5a7241b-9f6c-4d38-8e10-63b4a0c9d2ef",
    "symbol": "F:EURUSD",
    "side": "LONG",
    "quantity": "4625.3",
    "average_entry_price": "1.08100",
    "current_price": "1.08200",
    "unrealized_pnl": "4.63"
  }
]

Under netting the same three fills come back as a single position with id pos-F:EURUSD, one blended average_entry_price, and -13.81 of aggregate P&L — enough to tell you the basket is down, not enough to tell you the third leg is already profitable.

In hedging mode a position id is the trade id — the id of the order that opened it — so the OrderHandle returned by submit_order() already tells you the leg id you will see later, with no lookup needed. (With partial fills enabled one order can open several legs, ids suffixed -fill-1, -fill-2, …)

A worked scale-in grid

Adds a leg each time price falls a step below the lowest open entry, and closes each leg individually as it reaches its own profit target. Every position field is a string, so wrap the numerics in Decimal before comparing. The surrounding event loop and client setup are in Writing a Strategy.

from decimal import Decimal

from tektii import AsyncTradingGateway, CandleEvent

GRID_STEP = Decimal("0.0020")   # add a leg every 20 pips against us
TAKE_PROFIT = Decimal("25")     # close a leg once it is $25 up
MAX_LEGS = 5

class ScaleInGrid:
    def __init__(self, gw: AsyncTradingGateway) -> None:
        self._gw = gw

    async def on_candle(self, event: CandleEvent) -> None:
        bar = event.bar
        # Filter in Python — a backtest ignores the symbol= argument.
        legs = [p for p in await self._gw.list_positions() if p.symbol == bar.symbol]

        open_legs = []
        for leg in legs:
            if Decimal(leg.unrealized_pnl) >= TAKE_PROFIT:
                await self._gw.close_position(leg.id)   # closes only this fill
            else:
                open_legs.append(leg)

        if len(open_legs) >= MAX_LEGS:
            return

        lowest_entry = min(
            (Decimal(leg.average_entry_price) for leg in open_legs),
            default=None,
        )
        if lowest_entry is not None and Decimal(bar.close) > lowest_entry - GRID_STEP:
            return

        qty = await self._gw.quantity_for_notional(bar.symbol, notional="5000")
        await self._gw.submit_order(symbol=bar.symbol, side="buy", quantity=qty)

Under netting this strategy cannot work as written: list_positions() would return one blended position, unrealized_pnl would be the basket's, and there would be no per-leg id to close.

Closing a single leg

close_position(position_id) is the direct route — the gateway looks up that position, derives the opposite side, and submits a reduce-only market order targeting it. To control the close yourself, pass position_id to submit_order():

await self._gw.submit_order(
    symbol=leg.symbol,
    side="sell",              # must be the opposite side of the position
    quantity=leg.quantity,
    position_id=leg.id,
    reduce_only=True,
)

Three rules the engine enforces on a targeted close, each a rejection if broken:

  • The position must exist and still be open — closing an already-closed leg is an error, not a no-op.
  • The order symbol must match the position's symbol.
  • The order must be the opposite side of the position.

In hedging mode reduce_only=True also requires a position_id — without one there is no net position to reduce, so the engine rejects it rather than guessing which leg you meant.

Backtest behaviour to know