Skip to main content

Position lifecycle

This page covers the Position row, the outcome structs, the increase and decrease paths, the decrease lock, and the store that sweeps pending decrease orders. A position is one netted row per (user, is_long), stored under DataKey::Position(Address, bool). An account therefore holds at most one long row and one short row in a market.

Five entries change a row, and the table names the signer and the path each one runs.

EntrySignerEffect on the row
create_orderuserA decrease kind appends the order id to decrease_orders. Any kind stores a zeroed row when none exists.
cancel_orderuserA decrease kind removes the order id from decrease_orders.
execute_ordernoneAn increase kind runs Position::increase. A decrease kind runs Position::decrease with is_adl unset.
execute_adlnoneRuns Position::decrease with is_adl set and a withdrawal of 0.
execute_liquidationnoneRuns Position::liquidate.

The three keeper entries are permissionless. Their keeper argument names the reward recipient and never authorizes anything. Each one can change the size, the margin, and the accrual snapshots of the row.

The neighbouring pages own the rules this page uses. Margin and leverage gives the validity checks and settled equity. PnL and the profit cap gives the profit and loss (PnL) formula and the profit haircut. Funding rate gives the accrual settlement. Fee system gives Market::trade_fees, the impact fee ceiling, and the Settlement legs that pay a trader. Liquidation gives the forced close and its fee.

Units follow Units and scales. math::to_tokens_floor computes floor(notional * SCALAR_18 / price) and math::to_tokens_ceil computes ceil(notional * SCALAR_18 / price). Both take a token-dec notional and a feed-precision price and return a base-dec size. Seconds are the unix ledger timestamp. Every formula names the symbol that computes it.

One row holds one side of one account​

FieldTypeUnitMeaning
margini128token-decThe posted margin, net of every debited fee.
notionali128token-decThe size in quote. 0 means no open position.
tokensi128base-decThe size in base. The implied entry price is notional * SCALAR_18 / tokens, in feed precision.
funding_idxi128SCALAR_18The funding index of the side, snapshot at the last change.
borrowing_idxi128SCALAR_18The borrowing index of the side, snapshot at the last change.
locked_notionali128token-decThe notional under the decrease lock.
unlocks_atu64secondsThe lock deadline. locked_notional counts while now < unlocks_at.
priced_atu64secondsThe publish_time of the last fill's price.
decrease_ordersVec<u32>order idsThe pending decrease order ids on the side, at most MAX_ORDERS_PER_SIDE (8).

The row carries no PnL field, because PnL follows from tokens, notional, and the current price. Pricing gives the price floor that reads priced_at.

Position::store treats a row as closed when notional == 0 && margin == 0 && tokens == 0. The test reads those three fields alone, so a closed row can still carry decrease order ids. create_order stores a row that is all zero apart from that list when a decrease rests on a side that has never opened. For an increase order on a side with no row, it stores the plain zeroed row. The trader therefore pays the rent of the entry and the fill does not. Position::zeroed builds the canonical closed row, with every field at 0 and an empty decrease_orders list. A closed position persists as that row, so the prepaid rent of the entry stays alive for a later open.

get_position reads without writing​

fn get_position(e: Env, user: Address, is_long: bool) -> Position;

get_position needs no signer and raises no error. A hit extends the persistent time to live (TTL) of the row with LEDGER_THRESHOLD_USER and LEDGER_BUMP_USER. A miss returns the zeroed row and writes nothing. Storage gives the TTL values. The market reads the same row through Position::load:

fn load(e: &Env, user: &Address, is_long: bool) -> Position;
fn zeroed(e: &Env) -> Position;

Neither load nor zeroed raises an error, and load follows the same rule as get_position.

Each path returns one outcome struct​

Every position path returns one struct that the keeper entry publishes and settles. All amounts are token-dec unless marked.

Fees carries the itemized costs that one action settles:

FieldMeaning
baseThe skew-split trade fee.
impactThe size-quadratic impact fee on the full notional of the fill.
fundingThe signed funding accrual. A positive value is paid by the position and raises MarketData.credit_pool. A negative value is earned and raises ClaimableCredit(user).
borrowingThe borrowing accrual, never negative.

Fees::debit returns base + impact + borrowing + max(funding, 0), the total fee charge of the call. An increase debits it from the margin. A decrease pays it from realized profit first and debits only the uncovered remainder from the margin, as step 9 of Position::decrease shows. Earned funding never enters the debit. It is credited separately, under the rule on Funding rate.

Increase is the outcome of Position::increase:

FieldUnitMeaning
tokensbase-decThe base size bought at the entry price.
feesFeesThe costs that the increase settled.

Decrease is the outcome of Position::decrease, for a partial close and for a full close:

FieldUnitMeaning
notionaltoken-decThe closed size, the request clamped to the position.
tokensbase-decThe base size closed.
margintoken-decThe requested withdrawal on a partial close. The freed gross margin on a full close.
pnltoken-decThe realized PnL on the closed fraction, after the haircut, signed, gross of the settled costs.
returnedtoken-decThe payout owed to the trader, net of the settled costs.
bad_debttoken-decThe shortfall that the vault absorbs, made of fees and losses past the freed margin.
feesFeesThe costs that the decrease settled.

Liquidation is the outcome of Position::liquidate:

FieldUnitMeaning
notionaltoken-decThe force-closed size.
tokensbase-decThe force-closed base size.
margintoken-decThe freed margin, gross of the settled costs.
pnltoken-decThe realized PnL over the whole position, after the haircut, signed.
bad_debttoken-decThe shortfall that the vault absorbs.
returnedtoken-decThe remainder owed to the trader, net of liq_fee.
liq_feetoken-decThe liquidation fee charged.
feesFeesThe costs that the liquidation settled.

Increase::publish_receipt publishes OpenFill (topic open_fill) when opened is set, and IncreaseFill (topic increase_fill) otherwise. The keeper entry sets opened when the row held no size before the fill.

fn publish_receipt(&self, e: &Env, user: &Address, id: u32, is_long: bool, keeper: &Address, price: i128, notional: i128, margin: i128, opened: bool);

Decrease::publish_receipt publishes CloseFill (topic close_fill) when closed is set, and DecreaseFill (topic decrease_fill) otherwise. The keeper entry sets closed when the notional of the row is 0 after the fill.

fn publish_receipt(&self, e: &Env, user: &Address, id: u32, is_long: bool, keeper: &Address, price: i128, closed: bool);

Neither call raises an error. Events gives the payloads. The Liquidation outcome reaches the liquidation event through execute_liquidation, which Liquidation documents.

An increase buys size at the entry side​

fn increase(&mut self, e: &Env, market: &mut Market, user: &Address, is_long: bool, notional: i128, margin: i128) -> Increase;

notional and margin are the magnitudes of the filled order (token-dec), each at or above zero. The keeper entry has already resolved the effective price and advanced both accrual indices. market carries that price, the Config values, and the per-side aggregates that the call changes. is_long picks the side of every aggregate and of the two index pairs. user keys the claimable credit that earned funding writes.

A Position holds neither the account nor the side, so the caller passes the same user and is_long that it loaded the row under. Position::decrease and Position::liquidate take market, user, and is_long under the same rule.

Position::increase runs ten steps in this order:

  1. entry_price = price.entry(is_long) (feed precision). Pricing gives the side rule of PriceData::entry.
  2. tokens_added (base-dec) is math::to_tokens_floor(notional, entry_price) for a long and math::to_tokens_ceil(notional, entry_price) for a short. The entry size rounds against the trader. A floored short size would be worth less than its notional. A round trip at an unchanged price would then pay the trader up to one base unit.
  3. If notional > 0 and tokens_added == 0, the call traps SizeRoundsToZero (716). Such an increase would carry a notional with no exposure behind it. Only a long can trigger this trap, because the ceiling of a positive notional is at least 1.
  4. Position::settle_accruals settles the funding and the borrowing on the pre-fill notional. Both are 0 on a row with no size, and both are 0 when the two snapshots already equal the side indices.
  5. Market::trade_fees(is_long, notional, tokens_added) returns base and impact (token-dec). Both are 0 when notional is 0.
  6. The row and the side aggregate move together. The tokens of the row grows by tokens_added, its notional by the filled notional, and its margin by the posted margin minus fees.debit. The side of MarketData.tokens, MarketData.notional, and MarketData.margin takes the same three deltas. If the fees exceed the posted margin, the margin delta is negative.
  7. If notional > 0, locked_notional becomes the value of Position::locked at now plus notional, and unlocks_at = now + notional_lock (seconds). An expired bucket is replaced. A live bucket grows and its deadline resets.
  8. priced_at = price.publish_time.
  9. Position::require_valid runs with is_adl = false. It traps on the first violation, in this order: NotionalBelowMinimum (711), NotionalAboveMaximum (712), InsufficientMargin (713), PositionLiquidatable (723). Margin and leverage gives the conditions.
  10. If notional > 0 and the MarketData.notional of the side exceeds max_open_interest (token-dec), the call traps OpenInterestExceeded (715). Otherwise the call returns Increase { tokens: tokens_added, fees }.

A margin-only increase carries notional == 0. It buys no size, so it skips step 3. It pays no trade fee, leaves the lock bucket alone, and skips the open interest check at step 10. On a row with no size it traps NotionalBelowMinimum (711) at step 9, because min_position_notional (token-dec) is above zero.

A fill of an increase order also runs Market::require_utilization after Position::increase returns. That gate reads the increased side only and runs for a size-growing fill. Margin and leverage gives it.

A decrease pays fees from profit before margin​

fn decrease(&mut self, e: &Env, market: &mut Market, user: &Address, is_long: bool, notional: i128, margin: i128, is_adl: bool) -> Decrease;

notional is the size to close and margin is the requested withdrawal, both token-dec magnitudes. is_adl marks a forced close from execute_adl. Auto-deleveraging (ADL) reaches the same full-close clamp at step 4. The variables below are token-dec unless marked. self.notional and self.margin (token-dec) and self.tokens (base-dec) are the values of the row before the call. now is the ledger timestamp. price is the effective PriceData of the call (feed precision), resolved by Market::load.

Position::decrease runs fifteen steps in this order:

  1. Position::require_exists traps PositionNotFound (720) when self.notional is 0.
  2. Position::settle produces settled for the whole position. If settled.equity sits below the maintenance line, the call traps PositionLiquidatable (723). Liquidation is the only legal transition for a liquidatable position.
  3. locked is the value of Position::locked at now.
  4. The full-close clamp applies. If notional >= self.notional, or self.notional - notional < min_position_notional, the request is a full close. A full close with locked > 0 traps NotionalLocked (721). Otherwise the call returns close_settled(settled), and steps 5 to 15 do not run.
  5. A partial close with notional > self.notional - locked traps NotionalLocked (721).
  6. closed_tokens = math::prorate(self.tokens, notional, self.notional) = floor(self.tokens * notional / self.notional) (base-dec). The survivor keeps the implied entry.
  7. Market::trade_fees(is_long, -notional, -closed_tokens) returns base and impact for the closed fraction. funding and borrowing come from settled.fees, which step 2 settled once on the full pre-close size. fees = Fees { base, impact, funding, borrowing }.
  8. pnl = math::pnl(closed_tokens, notional, price, is_long) and capped_pnl = Market::haircut_pnl(is_long, pnl). The haircut reads the book before this decrease changes it.
  9. total_fees = fees.debit. profit = max(capped_pnl, 0). loss = min(capped_pnl, 0). covered = min(total_fees, profit). uncovered = total_fees - covered. The realized profit pays the fees first, and the margin pays the uncovered rest.
  10. withdrawal = min(margin, max(self.margin + loss - uncovered, 0)). The withdrawal claims last, so a withdrawal never creates bad debt.
  11. margin_change = loss - uncovered - withdrawal. next_margin = self.margin + margin_change. bad_debt = max(-next_margin, 0). The margin of the row becomes max(next_margin, 0). The side of MarketData.margin moves by margin_change + bad_debt, so the aggregate mirrors the floored row.
  12. priced_at = price.publish_time.
  13. self.notional -= notional and self.tokens -= closed_tokens. The side of MarketData moves by the same two deltas.
  14. Position::require_valid runs on the survivor with the given is_adl. It traps NotionalAboveMaximum (712), InsufficientMargin (713), or PositionLiquidatable (723). The clamp at step 4 keeps the survivor at or above min_position_notional, so NotionalBelowMinimum (711) cannot fire here. When is_adl is set, the call skips the 713 check.
  15. The call returns Decrease { notional, tokens: closed_tokens, margin, pnl: capped_pnl, returned: withdrawal + (profit - covered), bad_debt, fees }.

On a partial close, withdrawal from step 10 is the amount actually paid. It reaches the trader inside returned and not in margin.

A margin-only withdrawal carries notional == 0. The clamp at step 4 does not fire while the notional of the row is at or above min_position_notional. The request then runs as a partial close of size zero. Steps 6 to 8 give closed_tokens, base, impact, and capped_pnl of 0. The funding and borrowing accrued since the last change still debit the margin. Step 14 traps InsufficientMargin (713) when the remaining margin falls below the initial-margin floor, unless is_adl is set.

If a config change raised min_position_notional above the notional of the row, the clamp fires and the withdrawal becomes a full close. The margin argument is then ignored. The trader receives returned = max(settled.equity, 0), which is the whole settled equity and not the requested withdrawal. If any notional is still locked, the call traps NotionalLocked (721). It cannot trap InsufficientMargin (713).

Position::decrease holds one identity over the fields it returns on a partial close.

returned + fees.debit == (margin_before - margin_after) + pnl + bad_debt

Where:

  • margin_before and margin_after are the margin of the row before and after the call (token-dec).
  • pnl is capped_pnl from step 8 (token-dec, signed).
  • returned, bad_debt, and fees are the returned fields (token-dec).

In words, the payout plus the fees equals the margin the row loses, plus the PnL, plus the shortfall that the vault absorbs. The table runs steps 9 to 11 on a row of 100 margin with a requested withdrawal of 20 and a fees.debit of 3. The values are whole settlement tokens and illustrate the formula only.

capped_pnlcovereduncoveredwithdrawalnext_marginbad_debtreturned
+10302080027
-30032047020
-98030-110

In the third row the loss and the fee exceed the margin by 1. The margin of the row floors at 0 and the aggregate takes the bad_debt back. The identity reads 0 + 3 = (100 - 0) + (-98) + 1.

A full close settles the whole position at once​

fn close_settled(&mut self, market: &mut Market, is_long: bool, settled: Settled) -> Decrease;

Position::close_settled closes the whole position against the numbers from Position::settle. settled holds fees, pnl, and equity, each over the whole position and each with the haircut and the full-size trade fees applied. Margin and leverage defines settled.equity.

returned = max(settled.equity, 0) and bad_debt = max(-settled.equity, 0). The side of MarketData loses the full notional, tokens, and margin of the row. The notional, tokens, margin, locked_notional, and unlocks_at of the row become 0. close_settled leaves priced_at unchanged. Position::store then writes Position::zeroed over the closed row, which sets priced_at to 0.

The returned Decrease carries the full size, the gross margin from before the row zeroed, settled.pnl, and settled.fees. close_settled raises no error of its own, because every gate runs before it. Step 2 of Position::decrease traps a liquidatable row, so a full close on that path returns a bad_debt of 0. Position::liquidate runs the same function without that gate and then charges liq_fee from returned.

The decrease lock stops an open-then-decrease round trip​

Position::locked returns locked_notional while now < unlocks_at, and 0 after the deadline. Position::increase sets the bucket at step 7 on every fill that adds size. Config.notional_lock is the lock length in seconds, and Config gives its bounds.

A full close needs Position::locked to return 0 at now. A partial close is limited to self.notional minus the value that Position::locked returns at now. Both violations trap NotionalLocked (721).

The lock gates Position::decrease alone, so execute_adl meets it and Position::liquidate does not read it. The reason is the price window. The oracle bounds its trade-class staleness window by MAX_TRADE_STALENESS_SECONDS (15 seconds), and MIN_NOTIONAL_LOCK (15 seconds) is sized to that ceiling. The lock therefore outlasts the validity of any single accepted price, so one price cannot both open size and decrease that same size.

A closed row sweeps its pending decrease orders​

fn store(&self, e: &Env, user: &Address, is_long: bool) -> i128;

Position::store persists the row and returns the escrow (token-dec) that the caller owes back to user. It tests closed = notional == 0 && margin == 0 && tokens == 0. When the row survives, it stores self and returns 0.

When the row is closed, it runs the closure sweep over every id in decrease_orders. For each id it loads the order with get_order and adds the Order::escrow_amount of that order to the refund. It then removes the order and publishes CancelOrder (topic cancel_order) with refund: escrow, the escrow of that one order. An absent order row traps OrderNotFound (730). The sweep then stores Position::zeroed, so the list is empty on the next open. The caller pays the summed refund to the trader through its settlement.

set_position writes the row and extends its TTL with LEDGER_THRESHOLD_USER and LEDGER_BUMP_USER. A closed row is written too, as the zeroed row.

An increase never closes a row, so a fill through Position::increase never sweeps. On a full close through execute_order, the filled order leaves the list before the store, so the sweep never refunds it twice. execute_order publishes the fill receipt before the sweep. execute_adl and execute_liquidation store first, so their CancelOrder events precede the receipt.

fn push_decrease(&mut self, e: &Env, id: u32);
fn remove_decrease(&mut self, e: &Env, id: u32);

push_decrease appends a pending decrease order id. When the list already holds MAX_ORDERS_PER_SIDE (8) ids, it traps TooManyOrders (733). The cap bounds the closure sweep, so a full close stays inside the per-transaction limits. remove_decrease drops one id and keeps the rest in order. It raises no error, and an id that the list does not hold leaves the list unchanged. create_order pushes. cancel_order and a decrease fill remove.

The aggregates mirror the rows​

The sum over every position of notional, tokens, and margin on a side equals MarketData.notional, MarketData.tokens, and MarketData.margin for that side. Every step above applies the same delta to the row and to the aggregate.

On a partial close the margin of the row floors at zero. Position::decrease therefore adds bad_debt back into the margin aggregate, and the mirror holds. A full close and a liquidation move the aggregate by the whole margin of the row, which is never negative, so Position::close_settled needs no add-back.

A fill through execute_order is exactly one order. Position::increase and Position::decrease each consume that order in full, and the fill removes it either way. The full-close rule at step 4 clamps the size leg of a decrease, and step 10 clamps its margin leg again. A payout below the requested withdrawal still consumes the order, so an order never fills in parts.

execute_adl runs Position::decrease on an amount that the keeper supplies and carries no order, so its fill receipt reports the order id 0. An assigned order id starts at 1, so 0 never names a pending order.

The receipt of any fill therefore reconciles against the row and the aggregate. The row and the side aggregate change by the same size, token, and margin deltas. The identity above splits the margin leg into the payout, the fees, and the bad debt.