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 returned | Fills on a symbol collapse into one position per symbol | One position per open fill |
Position id | Synthetic, pos-<symbol> (e.g. pos-F:EURUSD) | The trade id — the id of the order that opened it |
average_entry_price | Weighted average across all fills | That single fill's entry price |
unrealized_pnl | Aggregate for the symbol | That leg's own P&L |
| Long and short at once | Not possible — they net off | Both can be open on the same symbol |
| Margin | Netted | Sum of all legs (no hedge benefit) |
A config that omits positionMode runs under netting. The scenario does not error and does not warn: a multi-entry strategy launches, runs to the end, and returns results that look ordinary. What changed is underneath. Every fill on a symbol merges into one aggregate position, so the per-leg ids your code closes against never exist — list_positions() gives back a single pos-<symbol>. unrealized_pnl is the whole basket's rather than one entry's, so a per-leg profit target never triggers on the leg you meant. close_position() on that id flattens the entire symbol, not a leg. And a sell entry reduces your open long instead of opening a short beside it. A grid, scale-in, ladder, or hedged pair run this way is not the strategy you wrote — it is a single netted position wearing its name.
Set positionMode to hedging explicitly whenever your strategy holds more than one entry per symbol. To check which mode a finished run actually used, read positionMode on the scenario record — tektii --output json scenario get <strategy-id> <scenario-id> always carries it.
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
list_positions(symbol=...) sends the symbol to the gateway, but the backtest engine's positions endpoint takes no query parameters — it returns every open leg, whatever the symbol. Against a live broker the filter applies; in a backtest it is silently dropped. If your scenario subscribes to more than one instrument, filter the returned list in Python, as the example above does. Mind close_all_positions(symbol=...) in particular: it closes whatever that same unfiltered lookup returns, so in a multi-instrument backtest it closes every open leg, not just the symbol you named.
realized_pnl on a position returned by a backtest is always 0 — realised P&L is reported per trade in the run results, not accumulated onto the open position. And opened_at / updated_at are stamped with wall-clock time at the moment of the request, not simulation time, so every leg looks like it opened just now and sorting by them is meaningless. Track entry order yourself — append leg ids to a list as you open them, or sort by average_entry_price, which is real.
Related
- Writing a Strategy — the event loop, order submission, and sizing
- Position Types — the full position schema and P&L definitions
- Scenario Configuration —
positionModeand every other scenario field - Execution & Costs — fills, spread, commission, and funding