perpsignal
August 4, 2026 · View on GitHub
A Python backtesting engine for perpetual futures. Write a strategy as a compact expression (or a JSON signal definition), evaluate it against real market data, and backtest it into honest metrics — Sharpe, return, drawdown, win rate — with fees, funding, stops/targets, and leverage modelled.
What sets it apart from general-purpose backtesters: funding and oi
(open interest) are first-class signal variables, and the data layer fetches
both from Hyperliquid alongside OHLCV. Funding is simultaneously a cost the
backtest charges against positions and a signal input your expressions can
read — most Python backtesting libraries model neither.
This is the open-source core extracted from Signalview, a non-custodial platform where backtested perps strategies are scored and traded by AI agents on Hyperliquid. The engine here has no wallet, key, custody, or live-trading code — it's a pure research/backtest library, safe to run anywhere.
⚠️ Research and backtesting only. Nothing here is financial advice, and a backtest is not a promise of future results. Perpetual-futures trading is high-risk.
Install
pip install perpsignal
# or, from source:
pip install git+https://github.com/mokshyaprotocol/signalview
Requires Python 3.10+, pandas, numpy, requests.
Backtest a Hyperliquid strategy in Python
End-to-end and runnable as-is — build_dataset fetches OHLCV, funding and open
interest for any Hyperliquid or Binance USD-M perp and caches to disk
(notebook version, or
open it in Colab):
from datetime import datetime, timezone
from perpsignal import evaluate, discretize, run, BacktestConfig, RiskConfig
from perpsignal.data import build_dataset
df = build_dataset(
"HYPEUSDT", "1h",
datetime(2026, 5, 1, tzinfo=timezone.utc),
datetime(2026, 8, 1, tzinfo=timezone.utc),
)
# columns: open, high, low, close, volume, quote_volume, taker_buy_quote,
# funding_rate, open_interest
# Funding fade: fade crowded positioning — short when funding is unusually
# high for the trailing 3 days, long when unusually negative.
cfg = BacktestConfig(symbol="HYPEUSDT", interval="1h")
score = evaluate("zscore(funding, 72) * -1", df)
position = discretize(score, cfg) # continuous score → {-1, 0, +1}
result = run(df, position, cfg, RiskConfig(), bars_per_year=24 * 365)
print(result.metrics)
The metrics include total_cost — fees and funding actually paid — plus
per-exit-reason counts (exits_sl, exits_tp, exits_flip, exits_flat),
turnover and exposure. On this particular strategy and window the engine
reports a heavy net loss after costs. That is the point: the engine's job is to
kill bad strategies on your laptop, not flatter them into production.
Quickstart on your own data
Any DataFrame with open, high, low, close, volume works (a DatetimeIndex is
ideal; funding_rate / open_interest columns are optional):
import pandas as pd
from perpsignal import evaluate, discretize, run, BacktestConfig, RiskConfig
df = pd.read_parquet("BTCUSDT-1h.parquet")
cfg = BacktestConfig(symbol="BTCUSDT", interval="1h") # holds the entry thresholds
# A score is any expression that evaluates to a Series (positive = long).
# This one is a mean-reversion fade: high when price is stretched below its
# 48-bar mean, low when stretched above.
score = evaluate("zscore(close, 48) * -1", df)
# Map the continuous score to a {-1, 0, +1} position via the config thresholds.
# run() expects a discrete position, not a raw score.
position = discretize(score, cfg)
result = run(
df, position, cfg,
RiskConfig(), # leverage / take-profit / stop-loss (sane defaults)
bars_per_year=24 * 365, # hourly bars
)
print(result.metrics) # {'sharpe': ..., 'total_return': ..., 'max_drawdown': ..., 'win_rate': ..., 'trades': ...}
The signal DSL
Expressions are built from market variables and functions.
Variables: close, high, low, open, volume, funding, oi
(open interest), bar_index.
Functions:
| Trend / momentum | rsi(close, n), ema(close, n), sma(close, n), slope(close, n), adx(n), macd-style via ema diffs |
| Volatility | atr(n), stdev(x, n), bb_width(close, n, k), bb_upper/bb_lower/bb_mid(close, n, k) |
| Normalization | zscore(x, n), clip(x, lo, hi), sign(x), abs(x), log(x), sqrt(x), tanh(x) |
| Volume / flow | vwap(n), session_vwap(), corr(a, b, n) |
| Windowing | highest(x, n), lowest(x, n), prev(x, k) |
| Logic | if(cond, a, b), min, max, comparisons (>, <, >=, ...), and/or |
Expressions are parsed by a real tokenizer→parser→evaluator (perpsignal.dsl)
and include an auto-repair pass (parse_with_repair) that fixes common
mistakes and reports what it changed — handy when the author is an LLM.
Gotcha: evaluate clips to [-1, +1] by default
evaluate(expr, df) clamps its output to [-1, +1] (so discretization
thresholds always see a bounded surface). Raw magnitudes like oi — millions
of dollars — saturate to a constant 1.0 under the default, which makes a
plot or a .describe() look broken when nothing is. Two correct ways to use
large-magnitude series:
evaluate("zscore(oi, 168)", df) # normalize INSIDE the expression (idiomatic)
evaluate("oi", df, clip=False) # or opt out when inspecting raw values
The same applies to close, volume, and any unbounded expression: normalize
with zscore / tanh / clip(x, lo, hi) inside the expression so the final
score is naturally in scoring range.
Funding rate and open interest as signal inputs
funding resolves to the venue's funding-rate series and oi to open
interest, both aligned to the OHLCV index by build_dataset. On venues or frames
without the data, both fall back to a zero series instead of raising — so
score + 0.1 * funding degrades gracefully rather than crashing a portfolio
sweep. Typical uses:
evaluate("zscore(funding, 72) * -1", df) # fade crowded funding
evaluate("sign(zscore(oi, 168)) * rsi(close, 14)", df) # gate momentum by OI regime
evaluate("if(funding > 0.0001, -1, 0)", df) # short only when longs pay
(Versions ≤ 0.1.0 had a bug where a bare funding silently read as zero —
fixed in 0.1.1; regression-tested since.)
Signals as data
A strategy can also be a JSON SignalDef (portable, diffable, PR-able):
from perpsignal import parse_signal
sig = parse_signal({
"asset": "BTCUSDT", "primary_tf": "1h",
"score": "rsi(close, 14) - 50",
# ... weights, regime config, risk bounds
})
See examples/ for runnable scripts and a sample signal file.
Fetching data
perpsignal.data pulls public Binance / Hyperliquid OHLCV, funding and open
interest and caches to disk. Symbols are Binance-style (BTCUSDT); anything
not listed on Binance USD-M (e.g. HYPEUSDT) transparently falls back to
Hyperliquid's public API, and namespaced HIP-3 builder-dex coins (xyz:TSLA)
go straight to Hyperliquid. An optional Upstash cache layer self-disables when
its env vars are unset — you never need it to run locally.
Is perpsignal right for you?
A good fit if you want to: research perps strategies that use funding or open interest as inputs; backtest with funding costs actually charged; express strategies as short auditable strings (including LLM-generated ones, via auto-repair); fetch Hyperliquid/Binance perps data without writing a client.
Not the tool if you need: live order execution (nothing here trades),
portfolio margin across books, order-book/tick-level simulation (it's
bar-based), or equities/spot-first workflows — general backtesters like
backtesting.py or vectorbt cover those better.
FAQ
Can I backtest a Hyperliquid strategy in Python with this?
Yes. perpsignal.data.build_dataset("HYPEUSDT", "1h", start, end) fetches
Hyperliquid OHLCV, funding and open interest into one DataFrame, and
perpsignal.run backtests any expression against it with fees and funding
charged. No API key is needed; the endpoints are public.
How is funding modelled in the backtest?
Twice, deliberately. The backtest charges the funding_rate series against
open positions bar by bar, pro-rated across the funding interval and scaled by
leverage (reported inside total_cost), and the DSL exposes the same series as
the funding variable so strategies can trade on it.
Does perpsignal place real trades? No. The package contains no exchange keys, wallets, or order code. It is a research and backtesting library only; live execution is out of scope by design.
What data does build_dataset return?
Columns open, high, low, close, volume, quote_volume, taker_buy_quote, funding_rate, open_interest on a UTC DatetimeIndex, fetched from Binance USD-M
when listed there and from Hyperliquid otherwise, cached to local parquet.
Why does my evaluate("oi", df) return only 0.0 and 1.0?
Because evaluate clips scores to [-1, +1] by default and raw open interest is
far outside that range. Normalize inside the expression
(zscore(oi, 168)) or pass clip=False when inspecting raw series.
Can an LLM write strategies for this engine?
That's a core use case. Strategies are single strings, parse_with_repair
fixes common LLM mistakes and reports its edits, and every metric is computed
from the same deterministic backtest — so generated strategies are judged on
numbers, not plausibility.
Contributing
New indicators, factors, and strategies are very welcome — this engine exists to be extended. A new built-in is usually one function plus one test. See CONTRIBUTING.md. Every PR is backtested in CI so improvements are judged on objective metrics, not opinion — which also makes this a clean target for autonomous/AI-agent strategy search.
License
Apache License 2.0. © 2026 Signalview.