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_pxandszare10**8times 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_iocandplace_perp_iocrun only on chain 998, because the indices are testnet indices.marketandperp_marketlet the script compare the on-chain markets with the info API before it sends.move_usdcmoves margin between spot and perp, as in tutorial 3.place_perp_ioctakesreduce_only. The close step sets it, so the order can only make the position smaller.
Steps
-
Deploy the contract:
-
Fund the contract with 18 USDC on Core. This send also activates it:
-
Show the buy plan. This step runs the order on a local copy of the chain and sends nothing:
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.
-
Buy:
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.
-
Sell as much HYPE as the size rules allow:
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.
-
Move 3 USDC to perp as margin:
-
Open a BTC long worth about 11 USD:
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.00013The precompile and the info API show the same position.
-
Close the whole position with a reduce-only sell:
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. -
Move the margin back to 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
--slippagewhen the testnet book is thin. - A wrong scale. CoreWriter uses
10**8for price and size. The price precompiles use other scales, so don't pass their values toplace_limit_orderdirectly. 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: