Skip to content

5. Place spot and perp orders

In this tutorial a contract trades on HyperCore. It buys and sells HYPE on spot, and opens and closes a small BTC perp position. It sends immediate-or-cancel (IOC) limit orders with core_writer.place_limit_order, and the script picks a valid price and size from the order book.

You'll learn:

  • How asset IDs work: 10000 plus the market index on spot (11035 for market 1035), and the perp index on perps (3 for BTC on testnet).
  • Why limit_px and sz are 10**8 times the human values, and how to round them to a valid tick and lot above the 10 USDC minimum.
  • How to read a perp position through precompiles.position, and close it with a reduce-only order.

Prerequisites

Item Amount
HYPE on HyperEVM less than 0.003 HYPE for gas
USDC in the spot balance of your Core account 19 USDC: 18 USDC to the contract and 1 USDC activation fee
HYPER_VYPER_TESTNET_KEY for all steps with --execute
HYPER_VYPER_CORE_FUNDER_KEY optional, for the fund step

Each order pays a trading fee of a few cents.

Order rules

The script takes these rules from the Hyperliquid documentation and from the market metadata:

Rule HYPE/USDC on testnet
Size decimals (sz_decimals of HYPE) 2: sizes like 0.24
Price At most 5 significant figures and at most 8 - sz_decimals = 6 decimals
Minimum order value 10 USDC
CoreWriter encoding limit_px and sz are 10**8 times the human value

These rules apply to the BTC perp on testnet:

Rule BTC perp on testnet
Size decimals (sz_decimals of BTC) 5: sizes like 0.00013
Price At most 5 significant figures and at most 6 - sz_decimals = 1 decimal
Minimum order value 10 USD
mark_px precompile Divide by 10**(6 - sz_decimals)
position precompile szi in units of 10**-sz_decimals: 13 is 0.00013 BTC

For a buy, the script takes the best ask, adds the slippage (default 1%) and rounds up to a valid tick. It then divides the USDC budget by the limit price and rounds the size down to the lot size. For a sell, it takes the best bid, subtracts the slippage and rounds down. The order function in tutorials/spot_order/run.py does this.

The contract

# pragma version 0.4.3
# pragma evm-version cancun
# pragma optimize gas
# SPDX-License-Identifier: MIT

"""
@title Tutorial: place IOC spot and perp orders
@notice The owner buys or sells HYPE for USDC on the Core spot market of
        testnet, and opens and closes a small BTC perp position, with
        immediate-or-cancel (IOC) limit orders.
@dev Tutorial code. Nobody has audited it. The markets are fixed: testnet spot
     market 1035 (HYPE/USDC) and testnet perp 3 (BTC). On mainnet the markets
     have other indices.
"""

from hyper_vyper import constants
from hyper_vyper import conversions
from hyper_vyper import core_writer
from hyper_vyper import precompiles


struct Status:
    evm_block: uint256
    core_exists: bool
    spot_usdc: uint64  # Core USDC, 8 decimals
    spot_hype: uint64  # Core HYPE, 8 decimals
    spot_hype_hold: uint64
    withdrawable: uint64  # perp USD, 6 decimals
    btc_szi: int64  # BTC position size, signed, in units of 10**-sz_decimals BTC
    btc_entry_ntl: uint64  # perp USD, 6 decimals


event OrderSubmitted:
    is_buy: bool
    limit_px: uint64
    sz: uint64


HYPE_USDC_SPOT_INDEX: constant(uint64) = 1035  # testnet
HYPE_USDC_ASSET: constant(uint32) = 11035  # 10000 + spot index
BTC_PERP: constant(uint32) = 3  # testnet. A perp's asset ID is its perp index.

OWNER: public(immutable(address))


@deploy
def __init__():
    OWNER = msg.sender


@internal
@view
def _only_owner():
    assert msg.sender == OWNER, "Only the owner"


@external
@view
def status() -> Status:
    usdc: precompiles.SpotBalance = precompiles.spot_balance(self, constants.USDC_TOKEN_INDEX)
    hype: precompiles.SpotBalance = precompiles.spot_balance(self, conversions.hype_token_index())
    btc: precompiles.Position = precompiles.position(self, BTC_PERP)
    return Status(
        evm_block=block.number,
        core_exists=precompiles.core_user_exists(self),
        spot_usdc=usdc.total,
        spot_hype=hype.total,
        spot_hype_hold=hype.hold,
        withdrawable=precompiles.withdrawable(self),
        btc_szi=btc.szi,
        btc_entry_ntl=btc.entry_ntl,
    )


@external
@view
def market() -> precompiles.SpotInfo:
    """The name and [base, quote] tokens of the market. Expect [1105, 0]."""
    return precompiles.spot_info(HYPE_USDC_SPOT_INDEX)


@external
@view
def perp_market() -> precompiles.PerpAssetInfo:
    """The name, size decimals and max leverage of the perp. Expect "BTC"."""
    return precompiles.perp_asset_info(BTC_PERP)


@external
def move_usdc(ntl: uint64, to_perp: bool):
    """Move ntl perp USD (6 decimals) between Core spot and Core perp."""
    self._only_owner()
    assert precompiles.core_user_exists(self), "Core account not activated"
    assert ntl > 0, "Zero amount"
    core_writer.transfer_usd_class(ntl, to_perp)


@external
def place_perp_ioc(is_buy: bool, limit_px: uint64, sz: uint64, reduce_only: bool):
    """Place an IOC limit order on the BTC perp. limit_px and sz are 10 ** 8 times
    the human values. A reduce-only order can only make the position smaller."""
    self._only_owner()
    assert chain.id == constants.TESTNET_CHAIN_ID, "Testnet market only"
    assert precompiles.core_user_exists(self), "Core account not activated"
    assert limit_px > 0 and sz > 0, "Zero price or size"
    core_writer.place_limit_order(BTC_PERP, is_buy, limit_px, sz, reduce_only, constants.TIF_IOC, 0)
    log OrderSubmitted(is_buy=is_buy, limit_px=limit_px, sz=sz)


@external
def place_ioc(is_buy: bool, limit_px: uint64, sz: uint64):
    """Place an IOC limit order. limit_px and sz are 10 ** 8 times the human values.
    The caller rounds them to the tick and lot rules of the market. Core rejects
    a bad order silently. The EVM transaction succeeds."""
    self._only_owner()
    assert chain.id == constants.TESTNET_CHAIN_ID, "Testnet market only"
    assert precompiles.core_user_exists(self), "Core account not activated"
    assert limit_px > 0 and sz > 0, "Zero price or size"
    core_writer.place_limit_order(
        HYPE_USDC_ASSET, is_buy, limit_px, sz, False, constants.TIF_IOC, 0
    )
    log OrderSubmitted(is_buy=is_buy, limit_px=limit_px, sz=sz)


@external
def sweep(token: uint64, amount_wei: uint64):
    """Send Core spot funds of this contract to the owner on Core (cleanup).
    amount_wei is in Core wei: 8 decimals for USDC and for HYPE."""
    self._only_owner()
    assert precompiles.core_user_exists(self), "Core account not activated"
    core_writer.send_asset(
        OWNER, empty(address), constants.SPOT_DEX, constants.SPOT_DEX, token, amount_wei
    )
  • The markets are constants: spot index 1035 (asset 11035) and perp 3 (BTC). Never take an asset from untrusted input.
  • place_ioc and place_perp_ioc run only on chain 998, because the indices are testnet indices.
  • market and perp_market let the script compare the on-chain markets with the info API before it sends.
  • move_usdc moves margin between spot and perp, as in tutorial 3.
  • place_perp_ioc takes reduce_only. The close step sets it, so the order can only make the position smaller.

Steps

  1. Deploy the contract:

    uv run --locked tutorials/spot_order/run.py deploy --execute
    
  2. Fund the contract with 18 USDC on Core. This send also activates it:

    uv run --locked tutorials/spot_order/run.py fund --execute
    
  3. Show the buy plan. This step runs the order on a local copy of the chain and sends nothing:

    uv run --locked tutorials/spot_order/run.py buy --usdc 12
    
    market @1035: tokens [1105, 0], HYPE sz_decimals 2
    order: IOC buy 0.25 HYPE at limit 47.390 (best 46.92)
      encoded: limit_px 4739000000, sz 25000000 (x 10**8)
      value at the limit: 11.84750 USDC
    plan: place_ioc(True, 4739000000, 25000000) value 0
    plan only: add --execute to send. Nothing was sent.
    

    Check the numbers: 46.92 x 1.01 = 47.3892, and the next valid tick up is 47.390 (5 significant figures). 12 / 47.390 = 0.253..., which rounds down to 0.25.

  4. Buy:

    uv run --locked tutorials/spot_order/run.py buy --usdc 12 --execute
    
    sent place_ioc: 0x... in block N
      block N+8: spot HYPE 0.249825, USDC 6.27
    filled: spot HYPE 24982500 (0.249825 HYPE, 8 decimals)
            spot USDC -1173000000 (-11.73 USDC, 8 decimals)
    

    The order filled at 46.92 USDC per HYPE, below the limit. Core takes the buy fee from the HYPE that you receive, so you get a little less than 0.25 HYPE.

  5. Sell as much HYPE as the size rules allow:

    uv run --locked tutorials/spot_order/run.py sell --execute
    
    order: IOC sell 0.24 HYPE at limit 45.626 (best 46.087)
      encoded: limit_px 4562600000, sz 24000000 (x 10**8)
      value at the limit: 10.95024 USDC
    sent place_ioc: 0x... in block N
      block N+8: spot HYPE 0.009825, USDC 17.32313739
    filled: spot HYPE -24000000 (-0.24 HYPE, 8 decimals)
            spot USDC 1105313739 (11.05313739 USDC, 8 decimals)
    

    The script rounds 0.249825 down to 0.24, and the rest stays on spot.

  6. Move 3 USDC to perp as margin:

    uv run --locked tutorials/spot_order/run.py to-perp --amount 3 --execute
    
  7. Open a BTC long worth about 11 USD:

    uv run --locked tutorials/spot_order/run.py perp-open --usd 11 --execute
    
    perp BTC (asset 3): sz_decimals 5, max leverage 40
    order: IOC buy 0.00013 BTC at limit 83978 (best 83146.0)
      encoded: limit_px 8397800000000, sz 13000 (x 10**8)
      value at the limit: 10.91714 USD
    plan: place_perp_ioc(True, 8397800000000, 13000, False) value 0
    sent place_perp_ioc: 0x... in block N
      block N+8: BTC szi 13
    position: szi 13 (0.00013 BTC), entry notional 10808980 (10.80898 USD, 6 decimals)
      info API: BTC szi 0.00013
    

    The precompile and the info API show the same position.

  8. Close the whole position with a reduce-only sell:

    uv run --locked tutorials/spot_order/run.py perp-close --execute
    
    order: IOC sell (reduce-only) 0.00013 BTC at limit 82313 (best 83145.0)
      encoded: limit_px 8231300000000, sz 13000 (x 10**8)
      value at the limit: 10.70069 USD
    plan: place_perp_ioc(False, 8231300000000, 13000, True) value 0
    sent place_perp_ioc: 0x... in block N
      block N+8: BTC szi 0
    position: szi 0 (0 BTC), entry notional 0 (0 USD, 6 decimals)
    The position is closed.
    
  9. Move the margin back to spot:

    uv run --locked tutorials/spot_order/run.py to-spot --execute
    
    move 2990143 (2.990143 USD, 6 decimals) perp -> spot
    

    The margin is a little less than 3 USD because of the trading fees.

What can go wrong

  • The order value is below 10 USDC. The price fell, or the budget is too small, so use a larger --usdc. For the sell, the HYPE that you bought must be worth 10 USDC or more at the limit. If it is not, buy with --usdc 14.
  • No fill. The IOC order did not match before Core canceled it, or Core rejected it. Both cases are silent and the EVM transaction still succeeds. Run the step again, or increase --slippage when the testnet book is thin.
  • A wrong scale. CoreWriter uses 10**8 for price and size. The price precompiles use other scales, so don't pass their values to place_limit_order directly.
  • Perp has no margin. Run the to-perp step before you open a position.
  • Close the BTC position first. The to-spot step moves only free margin.
  • Mainnet. The spot index 1035 and the perp index 3 are testnet indices, and the markets have other indices on mainnet. The contract refuses other chains.

Clean up

Send the remaining Core USDC and HYPE to the owner on Core:

uv run --locked tutorials/spot_order/run.py sweep --execute