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

Managing Exit Legs

An exit leg is the stop-loss or take-profit order that closes a position you already hold. When you submit an entry with stop_loss or take_profit, you never place those orders yourself — something places them for you, and what places them decides everything else on this page:

  • Where the broker supports a native bracket, the broker owns the legs. The Gateway forwards your entry and never sees them again.
  • Where it does not, the Gateway places them — as a native OCO pair, or as separate orders once the entry fills. These are the legs the Gateway tracks, and the only ones you can discover and move through the API.

Which path applies is a property of the broker, not of your order — see Placing Orders → Atomicity and check GET /v1/capabilities for bracket_orders. Everything below describes Gateway-placed legs.

When the legs appear

Nothing rests at the broker when you submit the entry. The Gateway registers the exits against your entry order and waits.

The legs are submitted after the entry fills — until then there is no position to protect, and a poll for the legs returns nothing. Both legs go out together: a stop-loss and a take-profit rest simultaneously, and neither waits on the other.

A partial fill places cover for the filled quantity only. Each further partial places another order for the newly filled amount, so one leg can rest as more than one order. The Gateway subtracts what is already covered before each placement, so the legs never over-hedge the position, and a remainder below the minimum exit quantity is skipped rather than sent as a dust order.

What a leg looks like

A leg is an ordinary order. It is not a special object, it appears in GET /v1/orders like anything else, and it is shaped from your entry:

FieldStop-loss legTake-profit leg
order_typeSTOP, or STOP_LIMIT if you gave a limit priceLIMIT
Trigger pricestop_pricelimit_price
sideOpposite of the entryOpposite of the entry
quantityThe filled quantity this order coversThe filled quantity this order covers
time_in_forceGTCGTC
client_order_id<entry>-sl<entry>-tp
position_idSet when the provider reported a position for the fillSame
reduce_onlytrue whenever position_id is setSame
parent_order_idThe entry order's IDThe entry order's ID

The <entry> seed in client_order_id is the entry's own client_order_id when you set one, and the server-assigned entry ID otherwise. Where a leg spans several orders because the entry filled in parts, the second and later orders carry an index — <entry>-sl-1, <entry>-sl-2.

When the legs go away

  • One leg triggers. The Gateway cancels its sibling, so a filled stop takes the take-profit off the book with it.
  • The position closes. Any leg still resting is cancelled. DELETE /v1/positions/{position_id} does this by default (cancel_associated_orders).

Both are the Gateway acting on your behalf. Do not cancel a sibling yourself on a fill — you will race the Gateway's own cancel.

Finding the legs for an entry

Every Gateway-tracked leg carries parent_order_id: the ID of the entry order it exits. It is returned on GET /v1/orders, GET /v1/orders/{order_id}, and GET /v1/orders/history, so a plain REST poll is all you need — no WebSocket subscription, no bookkeeping of your own.

There is no server-side filter for it — parent_order_id is not a query parameter. Match it in your own code:

from tektii import AsyncTradingGateway, Order

async def exit_legs_for(gw: AsyncTradingGateway, entry_order_id: str) -> list[Order]:
    """Every resting exit leg the gateway placed for one entry."""
    orders = await gw.list_orders()
    return [o for o in orders if o.parent_order_id == entry_order_id]

Because a leg is a normal order, the usual filters still narrow the poll — list_orders(symbol=...) or list_orders(status=["OPEN"]) before matching on the parent keeps the response small on a busy account.

Position modes change what else you get

A leg's position_id is whatever the provider reported for the fill that opened the position. On the backtest engine that is always the trade the entry opened — and whether that is a usable handle depends on the mode.

In hedging mode every fill opens its own trade and the engine reports each trade as its own position, so the trade ID is the position ID. The leg carries both handles — parent_order_id back to the entry, position_id naming the trade it protects — and either will find it.

In netting mode the two diverge. The leg still carries the ID of the trade the entry opened, but netting reports one blended position per symbol under a synthetic ID (pos-<symbol>), so the leg's position_id names nothing you can address. parent_order_id is the only reliable handle. Code that looks legs up by position works under hedging and silently finds nothing under netting, which is the trap worth designing around. See Position Modes for what else changes between the two.

parent_order_id is null whenever there is no Gateway-tracked leg behind the order:

  • The broker owns the bracket. On a native-bracket broker the legs are the broker's; the Gateway never placed them and reports no link. Those legs still appear in GET /v1/orders — they simply cannot be traced back to their entry this way.
  • The order is not an exit leg. Entries, and any order you placed yourself, carry nothing here.
  • The Gateway restarted. Tracking lives in the running process. A restart writes what was outstanding to the exit-state snapshot and logs it, but does not restore it — after a restart the legs keep resting at the broker while the Gateway no longer reports the link or cancels them on close. Reconcile by hand.

Moving a resting leg

Trailing a stop means moving the leg the Gateway is already tracking:

PATCH /v1/positions/{position_id}
Content-Type: application/json

{ "stop_loss": "1.0825" }

Both fields are optional and independent: stop_loss and take_profit each name that leg's new trigger price, and an omitted field leaves that leg untouched. Only legs the Gateway already placed can be moved — this does not attach protection to a position that has none.

The response returns the orders now holding each leg:

{
  "position_id": "pos_xyz789",
  "stop_loss": {
    "order_ids": ["ord_def456"],
    "trigger_price": "1.0825"
  }
}

Things worth knowing before you rely on it:

  • Order IDs can change. The Gateway prefers the provider's native modify, which is atomic and keeps the ID. Where the provider cannot modify in place it cancels the leg and re-places it, and the replacement is a new order with a new ID. Read the IDs back off the response — a cached leg ID may no longer exist.
  • A leg that filled in parts moves as a set. All of its resting orders go to the new price together, and they all come back in order_ids.
  • The two legs are not one transaction. They move one at a time. If a request names both and the second fails, the first stays at its new price. Re-read the position to see where each leg ended up.
  • 409 means you are still protected. Either the leg is not tracked, or the move was rejected and the Gateway re-placed the original — a failed move leaves the position exactly as covered as it was.
  • 502 means you are not. The leg was cancelled and neither the replacement nor the restore was accepted. The same condition is broadcast on the WebSocket as POSITION_UNPROTECTED; treat it as an open position with no cover.

The Python SDK does not wrap this endpoint yet — modify_order targets /v1/orders, not a position's legs. Call it over plain HTTP until an SDK method lands:

import httpx

async with httpx.AsyncClient(base_url="http://localhost:8080") as http:
    resp = await http.patch(
        f"/v1/positions/{position_id}",
        json={"stop_loss": str(new_stop)},
    )
    resp.raise_for_status()
    legs = resp.json()

When protection fails: POSITION_UNPROTECTED

POSITION_UNPROTECTED is an error event meaning exactly one thing: a position is open and one of its configured legs is not on the book. It fires when

  • the entry filled but a leg could not be placed — broker error, rate limit, dropped connection — and the retries were exhausted;
  • the exit circuit breaker was open, so placement was blocked after repeated failures; or
  • a PATCH /v1/positions/{position_id} cancelled a leg and could not re-establish it.

details carries what failed: order_id, symbol, failed_exits and circuit_breaker_open on the placement path; position_id, leg_type and cancelled_order_id on the move path.

If it fired while your WebSocket was down, the Gateway re-broadcasts it on reconnect rather than dropping it. Handle it idempotently — you can see the same unprotected position twice.

There is no safe way to ignore this event. Flatten, or re-arm and verify:

from tektii import ErrorEvent

async def on_error(self, event: ErrorEvent) -> None:
    if event.code != "POSITION_UNPROTECTED":
        return

    symbol = (event.details or {}).get("symbol")
    log.error("position unprotected symbol=%s details=%s", symbol, event.details)

    # Simplest correct response: stop being exposed.
    for position in await self._gw.list_positions():
        if position.symbol == symbol:
            await self._gw.close_position(position.id)

Re-arming instead — submitting a fresh reduce-only stop for the position — is legitimate, but only if you then confirm it rests. The condition that broke the first placement is often still there.

Backtest behaviour to know

  • Placing Orders — submitting the entry and its bracket legs
  • REST EndpointsPATCH /v1/positions/{position_id} and the rest of the position API
  • Order Types — the full order schema, including parent_order_id
  • Event TypesPOSITION_UNPROTECTED, OCO_DOUBLE_EXIT, and the other error codes
  • Position Modes — hedging vs netting, and what each writes back