Trading

The trading API is all about positions. The basic lifecycle here is:

  • Open a position, either:
    • By opening a position directly with a market order
    • Placing a limit order and waiting for the price trigger to open the position
  • Query the status of the position
  • If desired, update the poisition with activities such as:
    • Increase position size by adding collateral
    • Decreasing max gains
    • Decreasing leverage to decrease the position size
  • Close the position

We'll walk through these step by step. Before getting into the API, however, it's important for you to understand the meaning of the parameters for position. You can get familiar with these from the beta web interface or by reading our documentation, specifically the slides on:

As a very terse summary for those who haven't read the above, here's an overview of the parameters needed to open a position:

  • Required
    • Deposit collateral: how much of the collateral asset we want to deposit. This is provided by sending the funds to the market contract (either as native coins or CW20), not an extra execute message field.
    • Direction: are you trying to open a long or a short?
    • Leverage: how much additional exposure are you trying to achieve?
    • Max gains percentage: this represents the total potential profit in terms of the quote currency. This will determine how much collateral is borrowed from the liquidity pool and impact your max gains (or take profit) price.
  • Optional
    • Stop loss price
    • Limit order trigger price (if placing a limit order)
    • Slippage tolerance
    • Take profit override. (Note: this is an alternative to modifying the max gains parameter, and is not exposed in the UI)

Opening a position

Let's start off by opening a 5x leveraged long:

import { SigningCosmWasmClient } from "@cosmjs/cosmwasm-stargate"
import { DirectSecp256k1HdWallet } from "@cosmjs/proto-signing"
import { GasPrice } from "@cosmjs/stargate"

// Osmosis testnet RPC endpoint
// retrieved from https://github.com/cosmos/chain-registry/blob/aecb225b17d1cb5a3826887735cafeb7e62934d0/testnets/osmosistestnet/chain.json#L123
const rpcEndpoint = "https://rpc.osmotest5.osmosis.zone"

// Our personal wallet seed phrase
const seedPhrase =
  "hen punch extra relax craft bicycle iron core purity tissue talk impact kitchen inhale slush hip amateur during ranch inspire much correct century where"

// ATOM_USD contract address on Osmosis testnet
const marketAddress =
  "osmo1g2ml4wt93w72nmdysr99ptcvjxureqggjwrdmmx3d3fn75xuf9ts57atrx"

// Testnet CW20 contract for ATOM collateral
const collateralAddress =
  "osmo1g68w56vq9q0vr2mdlzp2l80j0lelnffkn7y3zhek5pavtxzznuks8wkv6k"
  
const runAll = async () => {
    const signer = await DirectSecp256k1HdWallet.fromMnemonic(seedPhrase, {
        // This is the prefix used by Osmosis testnet
        prefix: "osmo"
    })
    const accounts = await signer.getAccounts()
    const walletAddress = accounts[0].address

    const client = await SigningCosmWasmClient.connectWithSigner(
        rpcEndpoint,
        signer,
        // By specifying the cost of gas, we can let cosmjs determine the amount of
        // gas funds to include with transactions
        // this default is taken from https://github.com/cosmos/chain-registry/blob/aecb225b17d1cb5a3826887735cafeb7e62934d0/testnets/osmosistestnet/chain.json#L21
        { gasPrice: GasPrice.fromString("0.025uosmo") }
    )

    const msg = Buffer.from(
        JSON.stringify({
          open_position: { leverage: "5", direction: "long", max_gains: "+Inf" },
        })
      ).toString("base64");
    
    const execResult = await client.execute(
        walletAddress,
        collateralAddress,
        { send: { contract: marketAddress, amount: "5000000", msg } },
        "auto"
    );
    
    console.log(JSON.stringify(execResult));
}

runAll()

Since we're sending funds into this contract, we use the CW20 send method and base64-encoding of the message. We use the "+Inf" to mean "infinite max gains." This options is only available in collateral-is-base markets when opening a long. Besides "+Inf", we need to provide a value like "3.5" which would mean 350% max gains.

The events from the transaction above provide a lot of information on the actions we just took. For example, the wasm-position-open event gives us a bunch of parameters of the open position, including the position ID:

{
  "type": "wasm-position-open",
  "attributes": [
    {
      "key": "_contract_address",
      "value": "osmo1g2ml4wt93w72nmdysr99ptcvjxureqggjwrdmmx3d3fn75xuf9ts57atrx"
    },
    {
      "key": "active-collateral",
      "value": "4.936079717760488888"
    },
    {
      "key": "contract_name",
      "value": "levana.finance:market"
    },
    {
      "key": "contract_version",
      "value": "0.1.0-beta.15"
    },
    {
      "key": "counter-collateral",
      "value": "20"
    },
    {
      "key": "counter-leverage",
      "value": "1.999999999999999999"
    },
    {
      "key": "created-at",
      "value": "1679889642.398222464"
    },
    {
      "key": "deposit-collateral",
      "value": "5"
    },
    {
      "key": "deposit-collateral-usd",
      "value": "56.065"
    },
    {
      "key": "direction",
      "value": "long"
    },
    {
      "key": "levana_protocol",
      "value": "perps"
    },
    {
      "key": "leverage",
      "value": "5.051798419713133722"
    },
    {
      "key": "market-type",
      "value": "collateral-base"
    },
    {
      "key": "notional-size",
      "value": "-224.260000000000001498"
    },
    {
      "key": "notional-size-collateral",
      "value": "-19.999999999999999999"
    },
    {
      "key": "notional-size-usd",
      "value": "-224.260000000000001498"
    },
    {
      "key": "pos-id",
      "value": "99"
    },
    {
      "key": "pos-owner",
      "value": "osmo1g4unz9tzpdlaluqgyt0jjq5kyzuylg2a36086t"
    },
    {
      "key": "trading-fee",
      "value": "0.019999999999999999"
    },
    {
      "key": "trading-fee-usd",
      "value": "0.224259999999999988"
    }
  ]
}

You can use this data directly, and you can store your position IDs to query up-to-date information on them. But let's see how we would find our open positions and query them directly.

Query the open position

To get a list of open positions for a wallet, we use the NFT interface. Each position within Levana Perps is treated as a unique NFT that can be transferred or sold on secondary markets. We won't be using that functionality for now, just listing the positions.

const res = await client.queryContractSmart(marketAddress, {
  nft_proxy: { nft_msg: { tokens: { owner: walletAddress } } },
});

console.log(res);

You can provide all parameters supported by the CW721 spec, such as start_after and limit to enumerate through a large number of positions. In our case, we have just one position:

{ "tokens": [ '99' ] }

The position ID is internally represented as a 64-bit unsigned integer, so in order to have perfect support across systems that don't have this type, it's encoded as a string for on-the-wire messaging.

Once we have this ID, we can use it to look up the position information.

const res = await client.queryContractSmart(marketAddress, {
  positions: { position_ids: ["99"] },
});

console.log(res);

position_ids supports an array of position IDs to query, and will return a JSON structure like this:

{
  "positions": [
    {
      "owner": "osmo1g4unz9tzpdlaluqgyt0jjq5kyzuylg2a36086t",
      "id": "99",
      "direction_to_base": "long",
      "leverage": "4.991495369223277244",
      "counter_leverage": "1.999999999999999999",
      "created_at": "1695387960608855254",
      "liquifunded_at": "1695387960608855254",
      "trading_fee_collateral": "0.04",
      "trading_fee_usd": "0.28838",
      "funding_fee_collateral": "-0.001523571652950209",
      "funding_fee_usd": "-0.011006054395435988",
      "borrow_fee_collateral": "0.000020579249505979",
      "borrow_fee_usd": "0.00014866142924192",
      "crank_fee_collateral": "0",
      "crank_fee_usd": "0",
      "delta_neutrality_fee_collateral": "0.000535571339138809",
      "delta_neutrality_fee_usd": "0.004049209964128253",
      "deposit_collateral": "5",
      "deposit_collateral_usd": "36.0475",
      "active_collateral": "5.000699304454821595",
      "active_collateral_usd": "36.12430597108736481",
      "counter_collateral": "19.960268116609483826",
      "pnl_collateral": "0.000699304454821595",
      "pnl_usd": "0.07680597108736481",
      "dnf_on_close_collateral": "0.013100845148444754",
      "notional_size": "-144.190000000000000258",
      "notional_size_in_collateral": "-19.960268116609483825",
      "position_size_base": "24.960967421064305419",
      "position_size_usd": "180.314305971087364816",
      "liquidation_price_base": "5.811276064335014016",
      "liquidation_margin": {
        "borrow": "0.013663018160800488",
        "funding": "0.02204896776840991",
        "delta_neutrality": "0.124862826369046529",
        "crank": "0.001387058741937721"
      },
      "max_gains_in_quote": "+Inf",
      "take_profit_price_base": null,
      "entry_price_base": "7.209500000000000012",
      "next_liquifunding": "1695408222608855254",
      "stale_at": "1695416760608855254",
      "stop_loss_override": null,
      "take_profit_override": null
    }
  ],
  "pending_close": [],
  "closed": []
}

The top level JSON structure includes three keys:

  1. positions are positions which are currently open and active.
  2. pending_close are positions which need to be closed because of a liquidation, stop loss, or take profit. Once the crank runs, this position should move to closed.
  3. closed is positions that have been fully closed. We'll see some of that output towards the end of this chapter.

For more information on the meaning of each of these fields, please see the API reference docs.

Slippage tolerance

Slippage tolerance can be used to prevent a position from opening if the price has moved significantly from the time the trader tried to open the position. Let's simulate a failure by placing a slippage parameter when trying to open a short. To cause the failure, we'll put the expected price significantly higher than the current spot price:

const msg = Buffer.from(
  JSON.stringify({
    open_position: {
      leverage: "5",
      direction: "short",
      max_gains: "3.5",
      slippage_assert: { price: "100", tolerance: "0.01" },
    },
  })
).toString("base64");

const execResult = await client.execute(
  walletAddress,
  collateralAddress,
  { send: { contract: marketAddress, amount: "5000000", msg } },
  "auto"
);

This program produces the output:

Error: Query failed with (6): rpc error: code = Unknown desc = failed to execute message; message index: 0: dispatch: submessages: {
  "id": "slippage_assert",
  "domain": "market",
  "description": "Slippage is exceeding provided tolerance. Slippage is 791.99920844611191%, max tolerance is 1%. Current price: 11.209. Asserted price: 100.",
  "data": null
}: execute wasm contract failed [CosmWasm/[email protected]/x/wasm/keeper/keeper.go:425] With gas wanted: '0' and gas used: '225810' : unknown request

Our message asserted that the current price is 100 USD per ATOM, while the smart contract views the current price (at time of running) as 11.209. By stating 0.01 for the tolerance parameter, we're saying that the maximum the price can deviate against the trader is 1%. Therefore, this open position is rejected.

Stop losses

When you open a position, you can set a stop loss on the position. Let's actually open a short this time, and place a stop loss at a price above the current entry price but below the liquidation price:

const msg = Buffer.from(
  JSON.stringify({
    open_position: {
      leverage: "5",
      direction: "short",
      max_gains: "2.5",
      slippage_assert: { price: "11.209", tolerance: "0.01" },
      stop_loss_override: "11.5",
    },
  })
).toString("base64");

const execResult = await client.execute(
  walletAddress,
  collateralAddress,
  { send: { contract: marketAddress, amount: "5000000", msg } },
  "auto"
);

console.log(JSON.stringify(execResult));

Looking at the event output (which we won't show here for brevity), the position ID is "245155". We can query the open position to see the liquidation and stop loss prices:

const res = await client.queryContractSmart(marketAddress, {
  positions: { position_ids: ["245155"] },
});

const pos = res.positions[0];

console.log(`Unrealized PnL: ${pos.pnl_usd}`);
console.log(`Liquidation price: ${pos.liquidation_price_base}`);
console.log(`Stop loss: ${pos.stop_loss_override}`);

This outputs:

Unrealized PnL: -0.276837137536798332
Liquidation price: 13.055139080302147435
Stop loss: 11.5

Our position starts off with negative PnL since we had to pay fees from our collateral to open the position. Our stop loss price is exactly the 11.5 value we specified above, and our liquidation price is calculated to be about 13.06. We can modify our stop loss price if we want:

await client.execute(
  walletAddress,
  marketAddress,
  { set_trigger_order: { id: 245155, stop_loss_override: "12.98" } },
  "auto"
);

With that change in place, we now see our updated stop loss price:

Unrealized PnL: -0.199828868790545317
Liquidation price: 13.037564011474705624
Stop loss: 12.98

Placing a limit order

When you open an order using the open_position API, you create a market order that uses the current spot price. You can instead place a limit order. With limit orders, you're guaranteed to get the limit price you request or better. It's also possible that the order will be cancelled if, at the time of opening, there are any issues preventing the order from being placed, such as insufficient liquidity in the liquidity pool.

The parameters for placing a limit order look very similar to placing a market order, with two differences:

  • Limit orders require a trigger_price parameter
  • Limit orders do not support a slippage_assert parameter, since you specify the entry price yourself

Let's place a short limit order by setting a trigger price above the current spot price:

const msg = Buffer.from(
  JSON.stringify({
    place_limit_order: {
      leverage: "5",
      direction: "short",
      max_gains: "2.5",
      trigger_price: "11.159",
    },
  })
).toString("base64");

const execResult = await client.execute(
  walletAddress,
  collateralAddress,
  { send: { contract: marketAddress, amount: "5000000", msg } },
  "auto"
);

console.log(JSON.stringify(execResult));

Instead of receiving a position ID, we receive an order ID in the events. The wasm-place-limit-order event from the above execution gave me:

{
  "type": "wasm-place-limit-order",
  "attributes": [
    {
      "key": "_contract_address",
      "value": "osmo1g2ml4wt93w72nmdysr99ptcvjxureqggjwrdmmx3d3fn75xuf9ts57atrx"
    },
    {
      "key": "contract_name",
      "value": "levana.finance:market"
    },
    {
      "key": "contract_version",
      "value": "0.1.0-beta.15"
    },
    {
      "key": "deposit-collateral",
      "value": "4.999103621369666548"
    },
    {
      "key": "deposit-collateral-usd",
      "value": "55.770000000000000009"
    },
    {
      "key": "direction",
      "value": "short"
    },
    {
      "key": "levana_protocol",
      "value": "perps"
    },
    {
      "key": "leverage-to-base",
      "value": "-5"
    },
    {
      "key": "market-type",
      "value": "collateral-base"
    },
    {
      "key": "max-gains",
      "value": "2.5"
    },
    {
      "key": "order-id",
      "value": "187"
    },
    {
      "key": "pos-owner",
      "value": "osmo1g4unz9tzpdlaluqgyt0jjq5kyzuylg2a36086t"
    },
    {
      "key": "trigger-price",
      "value": "11.159"
    }
  ]
}

We can use the order-id of 187 above to check the status of our limit order:

const res = await client.queryContractSmart(marketAddress, {
  limit_order: { order_id: 187 },
});

console.log(JSON.stringify(res));

Resulting in:

{
  "order_id": 187,
  "trigger_price": "11.159",
  "collateral": "4.999103621369666548",
  "leverage": "5",
  "direction": "short",
  "max_gains": "2.5",
  "stop_loss_override": null,
  "take_profit_override": null
}

And if desired we can cancel our limit order:

await client.execute(
  walletAddress,
  marketAddress,
  { cancel_limit_order: { order_id: 187 } },
  "auto"
);

Add collateral

Let's get back to our existing open positions. Levana Perps provides a lot of flexibility to update existing positions. The first update mechanism we'll look at is adding collateral. When adding collateral, you get to decide whether the position should be updated by:

  • Increasing the position size, exposing you further to price movements, while keeping the leverage the same
  • Decrease the leverage, reducing your risk of liquidation, but not giving you more price exposure

We can see the former with the code:

const msg = Buffer.from(
  JSON.stringify({
    update_position_add_collateral_impact_size: {
      id: "99",
    },
  })
).toString("base64");

await client.execute(
  walletAddress,
  collateralAddress,
  { send: { contract: marketAddress, amount: "1000000", msg } },
  "auto"
);

And the latter with:

const msg = Buffer.from(
  JSON.stringify({
    update_position_add_collateral_impact_leverage: {
      id: "99",
    },
  })
).toString("base64");

const execResult = await client.execute(
  walletAddress,
  collateralAddress,
  { send: { contract: marketAddress, amount: "1000000", msg } },
  "auto"
);

Remove collateral

You can also choose to remove collateral, with the same decision to either decrease position size or increase leverage. This can be useful if you've experienced price movement in your favor and would like to take some profits.

Decreasing size:

await client.execute(
  walletAddress,
  marketAddress,
  {
    update_position_remove_collateral_impact_size: {
      id: "99",
      amount: "0.75",
    },
  },
  "auto"
);

Increasing leverage:

const execResult = await client.execute(
  walletAddress,
  marketAddress,
  {
    update_position_remove_collateral_impact_leverage: {
      id: "99",
      amount: "0.75",
    },
  },
  "auto"
);

Note that when passing in amount here, we use the decimal-encoded version of the value. 0.75 means 0.75 ATOM, or 750000uatom.

Change leverage and max gains

For our final set of updates, we can change leverage and max gains without adding or removing collateral. Updating the leverage will modify the position size (higher leverage means larger position), while updating max gains will impact your take profit price and borrow fees.

await client.execute(
  walletAddress,
  marketAddress,
  {
    update_position_leverage: {
      id: "99",
      leverage: "15",
    },
  },
  "auto"
);

await client.execute(
  walletAddress,
  marketAddress,
  {
    update_position_max_gains: {
      id: "99",
      max_gains: "2.3",
    },
  },
  "auto"
);

Closing a position

And finally, when we're done with a position, we can close it using the close_position message:

await client.execute(
  walletAddress,
  marketAddress,
  { close_position: { id: "99" } },
  "auto"
);

Once a position is closed, we can still query it:

const res = await client.queryContractSmart(marketAddress, {
  positions: { position_ids: ["99"] },
});

console.log(JSON.stringify(res));

But now that our position is closed, it will appear in the closed field instead of the positions field:

{
  "positions": [],
  "pending_close": [],
  "closed": [
    {
      "owner": "osmo1g4unz9tzpdlaluqgyt0jjq5kyzuylg2a36086t",
      "id": "99",
      "direction_to_base": "long",
      "created_at": "1679889642398222464",
      "liquifunded_at": "1679898724933076589",
      "trading_fee_collateral": "0.076983773508679359",
      "trading_fee_usd": "0.859852989798127206",
      "funding_fee_collateral": "0.000039121444204281",
      "funding_fee_usd": "0.000435428475475964",
      "borrow_fee_collateral": "0.000810696199482583",
      "borrow_fee_usd": "0.009022695213614841",
      "crank_fee_collateral": "0",
      "crank_fee_usd": "0",
      "delta_neutrality_fee_collateral": "0.037906133680913301",
      "delta_neutrality_fee_usd": "0.425282703238582748",
      "deposit_collateral": "5.5",
      "active_collateral": "5.283818160624093971",
      "pnl_collateral": "-0.216181839375906029",
      "pnl_usd": "-2.665508418238231754",
      "notional_size": "-829.985329366266828543",
      "entry_price_base": "11.213",
      "close_time": "1679898724933076589",
      "settlement_time": "1679898724933076589",
      "reason": "direct"
    }
  ]
}

Again, you can get more information on the meaning of each of these fields by looking at the message documentation.

With all that covered, you should now be set up to begin programmatic trading with Levana Perps!