A multi-user perpetual futures trading terminal for Hyperliquid, featuring a scripting engine that lets you write, test, and deploy automated trading strategies from your browser.
Built with a Rust backend (Axum + WebSocket) and a React frontend. Strategies are written in Rhai (a Rust-native scripting language), execute server-side with sandboxed resource limits, and can combine indicator signals across multiple assets and timeframes.
Browser (React) Server (Rust)
+-----------+ REST / WS +-------------------+
| Strategy | -------------> | Bot per user |
| Editor | | SignalEngine |
| Backtest | | -> Rhai scripts |
| Dashboard | <------------ | -> Indicators |
+-----------+ live state | -> Executor -> HL |
+-------------------+
- You connect your Hyperliquid wallet (Ethereum signature auth).
- You add markets, choose leverage, and allocate margin.
- You configure indicators per asset/timeframe and write a strategy using the scripting API (or use an existing one).
- The engine keeps those feeds hot, evaluates your scripts on each relevant candle update, and executes the resulting orders on Hyperliquid.
A strategy is three scripts that run at different stages of a trade lifecycle:
| Script | When it runs | Extra variable |
|---|---|---|
on_idle |
No position is open | is_armed -- expiry timestamp (or -1 if not armed) |
on_open |
A position is open | open_position -- current position info |
on_busy |
An order is pending (waiting for fill) | busy_reason -- opening or closing info |
Each script can return an Intent (a trading action) or return nothing (do nothing).
A strategy is attached to one market. Order helpers such as open_market, open_limit, flatten_*, and reduce_* act on that market.
Indicators are separate inputs. Each configured indicator is identified by asset + indicator kind + timeframe (internally this is the runtime IndexId), so one script can read BTC, SOL, ETH, and other signals side by side.
These are available in all three scripts:
| Variable | Type | Description |
|---|---|---|
free_margin |
f64 |
Available margin in USDC |
lev |
i64 |
Current leverage multiplier |
last_price |
Price |
Latest candle data for the current evaluation tick |
indicators |
Map |
Configured indicator values across all strategy assets/timeframes (use extract() instead of accessing directly) |
Sides
LONG SHORT
Timeframes
MIN1 MIN3 MIN5 MIN15 MIN30
HOUR1 HOUR2 HOUR4 HOUR12 DAY1
Order type
TAKER -- market order execution
Timeout actions
FORCE -- force-execute at market when timeout expires
CANCEL -- cancel the order when timeout expires
Scripts return an intent to tell the engine what to do. Return nothing (no explicit return, or ()) to skip the tick.
// Market orders
open_market(LONG, margin_pct(50.0))
open_market(SHORT, margin_amount(100.0))
open_market(LONG, margin_pct(100.0), triggers(5.0, 3.0))
// Limit orders
open_limit(LONG, margin_pct(50.0), 42000.0)
open_limit(SHORT, raw_size(0.5), 42000.0, triggers(5.0, 3.0))
open_limit(LONG, margin_pct(50.0), 42000.0, timeout(FORCE, timedelta(MIN15, 2)))
open_limit(LONG, margin_pct(50.0), 42000.0, timeout(CANCEL, timedelta(HOUR1, 1)), triggers(5.0, 3.0))// Close entire position at market
flatten_market()
// Close entire position with a limit order
flatten_limit(43000.0)
flatten_limit(43000.0, timeout(FORCE, timedelta(MIN5, 3)))
// Partial close
reduce_market(margin_pct(50.0))
reduce_limit(margin_pct(50.0), 43000.0)
reduce_limit(raw_size(0.1), 43000.0, timeout(CANCEL, timedelta(MIN15, 1)))// Abort: force close everything at market immediately
abort()
// Arm: delay entry -- on_idle will receive is_armed != -1 until expiry
arm(timedelta(MIN5, 3))
// Disarm: cancel the armed state
disarm()| Function | Description |
|---|---|
margin_pct(pct) |
Percentage of free margin (e.g. margin_pct(100.0) = all-in) |
margin_amount(usdc) |
Fixed USDC amount as margin |
raw_size(units) |
Exact number of asset units |
Size is converted to asset units at execution time using: (margin * leverage) / reference_price.
Triggers are specified as percentage values relative to entry price, adjusted for leverage.
triggers(5.0, 3.0) // 5% TP, 3% SL
tp_only(5.0) // TP only
sl_only(3.0) // SL only- TP must be positive
- SL must be positive and less than 100
Add indicators to your strategy through the editor UI. Each indicator is now bound to an asset and a timeframe, then exposed to Rhai through the indicators map.
Indicators are asset-scoped in scripts. For example:
let sol_rsi = extract("SOL_rsi_12_1h");
let btc_rsi = extract("BTC_rsi_12_1h");This lets a strategy attached to one market use confirmation signals from other assets without duplicating the scripting model.
| Indicator | Key format | Parameters |
|---|---|---|
| RSI | {asset}_rsi_{periods}_{tf} |
periods |
| EMA | {asset}_ema_{periods}_{tf} |
periods |
| SMA | {asset}_sma_{periods}_{tf} |
periods |
| ATR | {asset}_atr_{periods}_{tf} |
periods |
| ADX | {asset}_adx_{periods}_{di_length}_{tf} |
periods, DI length |
| Stochastic RSI | {asset}_stochRsi_{periods}_{k}_{d}_{tf} |
periods, K smoothing, D smoothing |
| SMA on RSI | {asset}_smaRsi_{periods}_{smoothing}_{tf} |
periods, smoothing length |
| EMA Cross | {asset}_emaCross_{short}_{long}_{tf} |
short period, long period |
| Volume MA | {asset}_volMa_{periods}_{tf} |
periods |
| Historical Volatility | {asset}_histVol_{periods}_{tf} |
periods |
Use extract() to access an indicator. It handles the lookup, null guard, and value unpacking automatically. You write one line and get several ready-to-use variables.
Single-value indicators (RSI, EMA, SMA, ATR, ADX, SMA on RSI, Volume MA, Historical Volatility):
let rsi = extract("BTC_rsi_14_15m");Expands to:
let rsi = indicators["BTC_rsi_14_15m"];
if rsi == () { return; }
let rsi_value = rsi.value.as_f64(); // the numeric value
let rsi_on_close = rsi.on_close; // true if from a closed candle
let rsi_ts = rsi.ts; // candle close timestamp (ms)After extract(), use rsi_value directly in your logic.
Stochastic RSI:
let stoch = extract("SOL_stochRsi_14_0_0_15m");Expands with:
let stoch_k = ... // K line
let stoch_d = ... // D line
let stoch_on_close = ...
let stoch_ts = ...EMA Cross:
let ema = extract("BTC_emaCross_9_21_15m");Expands with:
let ema_short = ... // short EMA value
let ema_long = ... // long EMA value
let ema_trend = ... // true if short > long (bool)
let ema_on_close = ...
let ema_ts = ...The variable names are always {your_name}_{field}. Clicking an indicator badge in the editor inserts the full asset-specific extract() call for you. If an asset symbol contains :, extract() normalizes it to _ during lookup.
last_price is the candle for the current evaluation tick:
| Field | Type | Description |
|---|---|---|
.open |
f64 |
Open price |
.high |
f64 |
High price |
.low |
f64 |
Low price |
.close |
f64 |
Close price |
.vlm |
f64 |
Volume |
.open_time |
i64 |
Candle open timestamp (ms) |
.close_time |
i64 |
Candle close timestamp (ms) |
Available in on_open as open_position:
| Field | Type | Description |
|---|---|---|
.side |
Side |
LONG or SHORT |
.size |
f64 |
Position size in asset units |
.entry_px |
f64 |
Average entry price |
.open_time |
i64 |
Position open timestamp (ms) |
Available in on_busy as busy_reason:
busy_reason.is_opening() // true if waiting for an open order to fill
busy_reason.is_closing() // true if waiting for a close order to fillDeclare persistent variables in the State Variables box in the editor. One per line, name = default:
count = 0
last_signal = "none"
prev_uptrend = null
These variables are automatically initialized on the first tick and persist across ticks. Use them as bare locals in your scripts — no state["..."] boilerplate.
// on_idle
count += 1;
if count > 10 {
open_market(LONG, margin_pct(50.0))
}Supported default types: numbers, strings ("..."), booleans (true/false), and null (Rhai () — useful for "not set yet").
A null default lets you check whether a value has been assigned:
if prev_uptrend != () {
// only runs after prev_uptrend has been set to a real value
}State resets when the strategy is reloaded or the bot restarts.
All examples below use explicit asset-prefixed indicator keys.
Indicators: BTC RSI(14) on 15m
on_idle:
let rsi = extract("BTC_rsi_14_15m");
if rsi_value < 30.0 {
open_market(LONG, margin_pct(50.0), triggers(3.0, 2.0))
} else if rsi_value > 70.0 {
open_market(SHORT, margin_pct(50.0), triggers(3.0, 2.0))
}on_open / on_busy: empty (TP/SL triggers handle the exit)
Attach this strategy to SOL. It uses BTC as a higher-timeframe filter and SOL for local trend / exit logic.
Indicators: BTC RSI(12) on 1h, SOL EMA Cross(9, 21) on 15m, SOL RSI(14) on 15m
State declarations:
prev_uptrend = null
on_idle:
let btc_rsi_1h = extract("BTC_rsi_12_1h");
let sol_ema = extract("SOL_emaCross_9_21_15m");
if is_armed > 0 {
if !prev_uptrend && sol_ema_trend {
prev_uptrend = sol_ema_trend;
return open_market(LONG, margin_pct(80.0), sl_only(28.0));
}
if prev_uptrend && !sol_ema_trend {
prev_uptrend = sol_ema_trend;
return open_market(SHORT, margin_pct(80.0), sl_only(28.0));
}
} else if btc_rsi_1h_value < 60.0 && !sol_ema_trend {
prev_uptrend = sol_ema_trend;
return arm(timedelta(MIN15, 1));
} else if btc_rsi_1h_value > 70.0 && sol_ema_trend {
prev_uptrend = sol_ema_trend;
return arm(timedelta(MIN15, 1));
}
prev_uptrend = sol_ema_trend;on_open:
let btc_rsi_1h = extract("BTC_rsi_12_1h");
let sol_rsi_15m = extract("SOL_rsi_14_15m");
let elapsed = last_price.open_time - open_position.open_time;
if open_position.side == LONG {
if sol_rsi_15m_value >= 68.0 || (elapsed > timedelta(MIN15, 2) && btc_rsi_1h_value < 33.0) {
return flatten_limit(last_price.close * 1.003);
}
} else {
if sol_rsi_15m_value <= 38.0 || (elapsed > timedelta(MIN15, 2) && btc_rsi_1h_value > 58.0) {
return flatten_limit(last_price.close * 0.997);
}
}on_busy: empty (wait for fill)
Indicators: BTC RSI(14) on 5m
on_idle:
let rsi = extract("BTC_rsi_14_5m");
if rsi_value < 25.0 {
// place a limit buy 0.1% below current price
// if not filled in 15 minutes, force-execute at market
let px = last_price.close * 0.999;
open_limit(LONG, margin_pct(40.0), px, timeout(FORCE, timedelta(MIN5, 3)), triggers(3.0, 1.5))
}on_open / on_busy: empty
Test your strategies against historical data before deploying. Configure:
- Strategy -- selected Rhai scripts, state declarations, and indicator set
- Traded asset -- the market being simulated
- Time range -- start and end timestamps
- Resolution -- candle timeframe for simulation
- Margin & Leverage -- initial capital and leverage
- Fees -- taker/maker fee in basis points
- Funding rate -- simulated funding rate per 8h
Results include equity curve, trade list, win rate, max drawdown, and position snapshots.
Scripts run inside a sandboxed Rhai engine with the following safety limits:
| Limit | Value |
|---|---|
| Max operations | 100,000 |
| Max expression depth | 64 |
| Max string size | 4,096 bytes |
| Max array size | 1,024 elements |
| Max map size | 256 entries |
| Min order value | $10 USDC |
Connect with any Ethereum-compatible wallet. The flow is:
- Request a nonce for your address
- Sign the nonce with your wallet
- Receive a JWT for authenticated API access
Your Hyperliquid API key is encrypted at rest and only decrypted server-side when your bot needs to execute trades.
Sides: LONG SHORT
Timeframes: MIN1 MIN3 MIN5 MIN15 MIN30 HOUR1 HOUR2 HOUR4 HOUR12 DAY1
Timeout: FORCE CANCEL
-- Sizing --
margin_pct(%) margin_amount($) raw_size(units)
-- Triggers --
triggers(tp%, sl%) tp_only(tp%) sl_only(sl%)
-- Timeouts --
timeout(action, timedelta(timeframe, count))
-- Open --
open_market(side, size)
open_market(side, size, triggers)
open_limit(side, size, px)
open_limit(side, size, px, triggers)
open_limit(side, size, px, timeout)
open_limit(side, size, px, timeout, triggers)
-- Close --
flatten_market()
flatten_limit(px)
flatten_limit(px, timeout)
reduce_market(size)
reduce_limit(size, px)
reduce_limit(size, px, timeout)
-- Control --
arm(timedelta) disarm() abort()
-- Indicators --
Key format: {ASSET}_{indicator}_{params}_{tf}
extract("BTC_rsi_14_15m") → {name}_value, {name}_on_close, {name}_ts
emaCross: {name}_short, {name}_long, {name}_trend
stochRsi: {name}_k, {name}_d
-- State --
Declare in State Variables box: count = 0 | flag = false | x = null
Use as bare locals: count += 1