Scaffold this example locally and test it with the CLI:
circuit new --name my-hyperliquid-agent --language python --template hyperliquid
cd my-hyperliquid-agent
circuit run # execute a run cycle
circuit unwind # test the unwind logic
circuit.toml
circuit.toml
name = "Example ETH Perp Agent"
tagline = "Buys ETH perps each cycle"
category = "quant"
imageUrl = "https://api.circuit.org/assets/agents/default"
walletVmFamily = "evm"
allowedExecutionModes = ["auto", "manual"]
[[triggers]]
type = "schedule"
every = 60
align = "rolling"
[startingAsset]
network = "hypercore:perp" # Hyperliquid Perpetuals
address = "USDC" # USDC on Hyperliquid
minimumAmount = "2000000000" # 20 USDC (8 decimals; Circuit raw units for hypercore:perp USDC)
Example
from circuit_sdk import AgentContext, base_units_to_decimal, decimal_to_base_units
BUY_SIZE = 0.01 # ETH to buy each cycle
# Fetch one current midpoint through the SDK. This reads Hyperliquid at call
# time, so call it once per decision and reuse the result.
def get_hyperliquid_mid(agent: AgentContext, coin: str) -> float:
result = agent.platforms.hyperliquid.midpoint_price(coin)
if not result.success or result.data is None or isinstance(result.data, list):
raise RuntimeError(result.error or f"No mid price for {coin}")
return float(result.data.price_usd)
def run(agent: AgentContext) -> None:
# Size from THIS session's allocation - the perp collateral assigned to it,
# never a whole-wallet read (which on a shared wallet would overspend the
# other sessions sharing it).
allocation = agent.allocation
cash = next(
(b for b in allocation.balances if b.hyperliquid_metadata and b.hyperliquid_metadata.collateral),
None,
)
if cash is None:
agent.log("No perp collateral in this session's allocation")
return
# Exact raw math for the collateral gate - never float-divide an amount_raw.
agent.log(f"Allocated collateral: ${base_units_to_decimal(cash.amount_raw, cash.decimals)}")
if int(cash.amount_raw) < int(decimal_to_base_units("20", cash.decimals)):
agent.log("Not enough collateral to place order")
return
# Check existing positions from this session's live scoped share.
for pos in allocation.positions:
if pos.network != "hypercore:perp":
continue
agent.log(f"Open: {pos.coin} {pos.size} @ {pos.average_entry_price} (PnL: {pos.unrealized_pnl_usd})")
# Place a market buy for ETH perps
# price acts as slippage limit for market orders - derive it from the live mid
eth_mid = get_hyperliquid_mid(agent, "ETH")
order = agent.platforms.hyperliquid.place_order({
"coin": "ETH",
"side": "buy",
"size": BUY_SIZE,
"price": eth_mid * 1.01, # 1% above mid - tolerated slippage for a market buy
"market": "perp",
"type": "market",
})
if order.success and order.data:
agent.log(f"Order placed: {order.data.order_id} ({order.data.status})")
# Track total buys in memory
prev = agent.memory.get("totalBuys")
count = int(prev.data.value) + 1 if prev.data and prev.data.value is not None else 1
agent.memory.set("totalBuys", str(count))
agent.log(f"Total buy orders placed: {count}")
else:
agent.log(order.error or "Order failed", error=True)
def unwind(agent: AgentContext) -> None:
allocation = agent.allocation
# Close this session's perps, sized from its allocation (its scoped share) - never a
# whole-wallet read, which on a shared wallet would close other sessions' perps.
for pos in allocation.positions:
if pos.network != "hypercore:perp":
continue
# pos.size is a signed decimal string: long > 0, short < 0. Keep it exact -
# strip the sign for the venue size instead of float math.
is_short = pos.size.startswith("-")
abs_size = pos.size[1:] if is_short else pos.size
if float(abs_size) == 0:
continue
close_side = "buy" if is_short else "sell"
# Derive the slippage bound from a live mid; wide (50%) to guarantee the reduce-only fill.
mid = get_hyperliquid_mid(agent, pos.coin)
slippage_price = mid * 0.5 if close_side == "sell" else mid * 1.5
close_order = agent.platforms.hyperliquid.place_order({
"coin": pos.coin,
"side": close_side,
"size": float(abs_size), # venue-human number; representation-only conversion
"price": slippage_price,
"market": "perp",
"type": "market",
"reduceOnly": True,
})
if close_order.success and close_order.data:
agent.log(f"Closed {pos.coin}: {close_order.data.status}")
else:
agent.log(f"Failed to close {pos.coin}: {close_order.error}", error=True)
import { type AgentContext, baseUnitsToDecimal, decimalToBaseUnits } from "circuit:sdk";
const BUY_SIZE = 0.01; // ETH to buy each cycle
// Fetch one current midpoint through the SDK. This reads Hyperliquid at call
// time, so call it once per decision and reuse the result.
async function getHyperliquidMid(agent: AgentContext, coin: string): Promise<number> {
const result = await agent.platforms.hyperliquid.midpointPrice(coin);
if (!result.success || !result.data || Array.isArray(result.data)) {
throw new Error(result.error || `No mid price for ${coin}`);
}
return parseFloat(result.data.priceUsd);
}
export async function run(agent: AgentContext): Promise<void> {
// Size from THIS session's allocation - the perp collateral assigned to it,
// never a whole-wallet read (which on a shared wallet would overspend the
// other sessions sharing it).
const allocation = agent.allocation;
const cash = allocation.balances.find((b) => b.hyperliquidMetadata?.collateral);
if (!cash) {
await agent.log("No perp collateral in this session's allocation");
return;
}
// Exact raw math for the collateral gate — never float-divide an amountRaw.
await agent.log(`Allocated collateral: $${baseUnitsToDecimal(cash.amountRaw, cash.decimals)}`);
if (BigInt(cash.amountRaw) < BigInt(decimalToBaseUnits("20", cash.decimals))) {
await agent.log("Not enough collateral to place order");
return;
}
// Check existing positions from this session's live scoped share.
for (const pos of allocation.positions) {
if (pos.network !== "hypercore:perp") continue;
await agent.log(`Open: ${pos.coin} ${pos.size} @ ${pos.averageEntryPrice} (PnL: ${pos.unrealizedPnlUsd})`);
}
// Place a market buy for ETH perps
// price acts as slippage limit for market orders - derive it from the live mid
const ethMid = await getHyperliquidMid(agent, "ETH");
const order = await agent.platforms.hyperliquid.placeOrder({
coin: "ETH",
side: "buy",
size: BUY_SIZE,
price: ethMid * 1.01, // 1% above mid - tolerated slippage for a market buy
market: "perp",
type: "market"
});
if (order.success && order.data) {
await agent.log(`Order placed: ${order.data.orderId} (${order.data.status})`);
// Track total buys in memory
const prev = await agent.memory.get("totalBuys");
const count = prev.data?.value != null ? parseInt(prev.data.value) + 1 : 1;
await agent.memory.set("totalBuys", count.toString());
await agent.log(`Total buy orders placed: ${count}`);
} else {
await agent.log(order.error || "Order failed", { error: true });
}
}
export async function unwind(agent: AgentContext): Promise<void> {
const allocation = agent.allocation;
// Close this session's perps, sized from its allocation (its scoped share) - never a
// whole-wallet read, which on a shared wallet would close other sessions' perps.
for (const pos of allocation.positions) {
if (pos.network !== "hypercore:perp") continue;
// pos.size is a signed decimal string: long > 0, short < 0. Keep it exact —
// strip the sign for the venue size instead of float math.
const isShort = pos.size.startsWith("-");
const absSize = isShort ? pos.size.slice(1) : pos.size;
if (Number(absSize) === 0) continue;
const closeSide = isShort ? "buy" : "sell";
// Derive the slippage bound from a live mid; wide (50%) to guarantee the reduce-only fill.
const mid = await getHyperliquidMid(agent, pos.coin);
const slippagePrice = closeSide === "sell" ? mid * 0.5 : mid * 1.5;
const closeOrder = await agent.platforms.hyperliquid.placeOrder({
coin: pos.coin,
side: closeSide,
size: Number(absSize), // venue-human number; representation-only conversion
price: slippagePrice,
market: "perp",
type: "market",
reduceOnly: true
});
if (closeOrder.success && closeOrder.data) {
await agent.log(`Closed ${pos.coin}: ${closeOrder.data.status}`);
} else {
await agent.log(`Failed to close ${pos.coin}: ${closeOrder.error}`, { error: true });
}
}
}
Sample Output
Agent run started
Withdrawable balance: $96.67
Open: BTC long 0.00031 @ 68237.0 (PnL: -0.26629)
Open: ETH long 0.021 @ 1951.33 (PnL: -0.175046)
Open: SOL long 0.13 @ 84.979 (PnL: -0.24856)
Order placed: 327024162229 (filled)
Total buy orders placed: 1
Agent run completed
Agent unwind started
Closed BTC long: filled
Closed ETH long: filled
Closed SOL long: filled
Agent unwind completed
How It Works
- Check balance: Reads withdrawable USDC from the perp account
- Log positions: Shows any existing open positions with PnL
- Place order: Buys a small ETH perp position with a market order
- Track state: Uses memory to count total buy orders across cycles
- Unwind: Closes all open positions with reduce-only market orders
Notes
- The
pricefield on market orders acts as a slippage limit - set it above current market for buys, below for sells. reduceOnly: trueensures the close order can only reduce an existing position, not open a new one.- Hyperliquid amounts are formatted values (e.g.,
0.01ETH), not raw units like EVM chains. - See Hyperliquid tick and lot sizes for minimum order sizes per symbol.