Botmarley
Botmarley is a self-hosted cryptocurrency trading bot that runs on your own hardware. You write trading strategies, test them against historical data, and run them live on Kraken and Binance. No cloud service holds your keys, and nobody else touches your funds.
You install it, you run it, you own it.
What you can do with it
- Build strategies in a visual editor or as raw TOML, with live validation.
- Backtest against historical candle data before you risk real money.
- Trade live on Kraken and Binance, with start, pause, and resume controls.
- Track your portfolio value over time in USD and BTC.
- Rotate between assets with momentum strategies like GEM (see GEM / Dual Momentum).
- Get notified on Telegram when trades fire or sessions change state.
How this manual is laid out
If you just want it running, start with Installation and Quick Start. Want to see it first? There's a live demo you can click around without installing anything.
From there, the Strategies section covers how to define trading rules, the Backtesting section covers testing them, and Live Trading covers running them for real.
When you're ready to host it properly, Production Deployment and Licensing & KeyMarley cover the server side.
A note on safety
Backtest results are not a promise of future returns. Test every strategy, start small, and never run money you can't afford to lose. Botmarley gives you the tools; the decisions are yours.
Installation
Botmarley ships as a single server binary plus a PostgreSQL database. You need both running.
Requirements
- A Linux, macOS, or Windows machine you control (a small VPS is plenty).
- Docker, for the database. Or your own PostgreSQL if you prefer.
- About 200 MB of disk for the binary and growing space for market data.
1. Get the binary
Download the latest release from GitHub Releases and put it somewhere on your PATH, or just keep it in a working folder.
2. Start PostgreSQL
The quickest way is Docker:
docker compose up -d postgres
That starts a database with the default development credentials. For a real deployment, set your own connection string with the DATABASE_URL environment variable. See Production Deployment.
3. Run the server
./server
On first start it creates the tables it needs and writes its working files to ~/.botmarley.
4. Open the app
Go to http://localhost:3000. You should see the dashboard.
That's the whole install. Next, walk through the Quick Start to run your first backtest, or open the live demo to look around first.
Where Botmarley keeps its files
~/.botmarley/dataholds historical candle data in Apache Arrow format.~/.botmarley/.license_cacheholds your validated licence (signed, with a grace period).- Strategies live in
strats/next to the binary, split intobuiltin,example, anduser.
Environment variables
| Variable | What it does |
|---|---|
DATABASE_URL | PostgreSQL connection string. Overrides the default. |
KEYMARLEY_VALIDATE_URL | Licence server endpoint. Defaults to the hosted one. See Licensing. |
BACKTEST_SLIPPAGE_BPS | Optional slippage in basis points applied during backtests. Off by default. |
BOTMARLEY_DEMO_MODE | Set to 1 to run an open public demo with no licence gate. Leave unset for normal use. |
Quick Start
This walks you from a fresh install to your first backtest in a few minutes. If you haven't installed yet, do that first (Installation).
1. Download some market data
Open the Market Data page. Pick a pair (say BTC/USDC) and download recent history. Botmarley stores everything as 1-minute candles and builds longer timeframes from those, so you only ever fetch 1m.
2. Pick a strategy
Open the Strategies page. You'll see built-in and example strategies. Start with one of the examples, like the EMA crossover. You can read exactly what it does in the Strategy Editor.
3. Run a backtest
From a strategy, hit Backtest. Choose the pair and date range, set your starting capital, and run it. You'll get back PnL, win rate, drawdown, an equity curve, and every trade it would have made. Details in Backtesting.
4. Go live (when you're ready)
Once a strategy holds up across different periods and pairs, you can run it live. Add your exchange API keys in settings, then start a live session with start, pause, and resume controls. See Live Trading.
Start small. A backtest that looks great can still lose money live if the strategy is overfitted.
Live Demo
Want to see Botmarley before installing anything? There's a public demo you can click around right now:
No sign-up, no licence key, no install. It's open to anyone.
What you can do there
- Browse the dashboard and see how sessions and PnL are laid out.
- Open the strategy editor and read the built-in strategies.
- Run a backtest against historical data and read the results: equity curve, trade list, stats.
- Look at the market data charts with indicators.
What it is and isn't
The demo is a sandbox. It's a separate instance with its own data, completely independent of any real deployment, and it isn't wired to anyone's exchange account or funds. It's there to show you the interface and let you try a backtest, not to trade.
It may reset from time to time, so don't treat anything you do there as permanent.
Ready for the real thing?
When you want your own instance, with your own strategies and your own keys, head to Installation.
Strategy Editor
A strategy is a set of rules that decide when to buy and when to sell. Botmarley stores each one as a TOML file, and you can edit it two ways: a visual builder, or the raw TOML with live validation.
The shape of a strategy
Most strategies are per-pair trigger strategies. They watch one trading pair and fire actions when conditions are met. A strategy file has a [meta] block and one or more [[actions]]:
[meta]
name = "EMA(9) Cross Above EMA(21)"
description = "Classic trend-following entry with a small DCA grid, take profit at +2%"
max_open_positions = 1
# Entry: buy 100 USDC when the 9 EMA crosses above the 21 EMA on the 1h timeframe
[[actions]]
type = "open_long"
amount = "100 USDC"
[[actions.triggers]]
indicator = "ema_9"
operator = "cross_above"
target = "ema_21"
timeframe = "1h"
# Take profit: sell the whole position at +2%
[[actions]]
type = "sell"
amount = "100%"
[[actions.triggers]]
type = "pos_price_change"
value = "+2%"
Actions
| Action | What it does |
|---|---|
open_long | Open a new position |
buy | Add to an existing position (DCA) |
sell | Close some or all of a position |
amount is either a fixed quote amount ("100 USDC") or a percentage ("100%").
Triggers
A trigger is the condition that makes an action fire. The common one is technical: compare an indicator to a value or another indicator.
indicatorandtargetname the two sides (e.g.ema_9vsema_21, orrsi_14vs a number).operatoriscross_above,cross_below,greater_than,less_than, orequal.timeframeis the candle size the indicator runs on (1m,1h,1d, and so on).
There are also price-change triggers (move relative to entry) and time-based triggers (after N candles). The full indicator list is in the Indicators Reference.
Volatility-scaled thresholds (×ATR)
A fixed +2% take-profit that works on calm BTC barely moves the needle on a pair that swings 8% a day, and the reverse over-trades. Instead of hand-tuning a percentage per pair, a price-change threshold can scale with volatility using ATR (Average True Range).
In the editor, every price-change value has a unit selector next to it:
- % — a plain fixed percentage, the classic behaviour.
- ×ATR — the number you type is a multiplier
k. The real threshold isk × ATR(period) ÷ price, so one strategy adapts itself across pairs and market regimes.
Picking ×ATR reveals an ATR period field (default 14) and a recompute live toggle. In TOML the value reads k*atr_period:
# Take profit at 0.6 × ATR(14) above entry — tighter on calm pairs, wider on wild ones
[[actions]]
type = "sell"
amount = "100%"
[[actions.triggers]]
type = "pos_price_change"
value = "0.6*atr_14"
The sign of k sets direction (a DCA buy uses a negative multiplier, a take-profit a positive one), exactly like a % value. The ATR runs on the trigger's own Timeframe, so atr_14 on a 1d trigger is the daily ATR.
Frozen at entry (default). The threshold is resolved once, when the position opens: k × ATR(closed bars up to the entry tick) ÷ entry price. It then stays fixed for the life of that position, so the target doesn't drift tick to tick. During warm-up — before there are enough closed bars to compute the ATR — the trigger stays inert and won't fire.
Recompute live (opt-in). Tick the recompute live box (TOML token _live, e.g. -3*atr_14_live) and the ATR is re-read at the current tick every time the trigger is checked, so a DCA or take-profit adapts to the current pace instead of the pace at entry.
You can mix units freely: a strategy can have a % entry and a ×ATR exit, or different units on different triggers.
Tolerance sign matters. On a Follow trigger (
pos_price_change_follow), a profit-take (positive activation) needs a negative tolerance, and a dip-buy (negative activation) needs a positive one. Same-sign activation and tolerance disables the trailing behaviour — the action fires immediately on activation. The editor shows a warning when the signs disable the trail.
Universal by design
A trigger strategy never names a specific pair. The same rules run against whatever pair you point them at, so you can backtest one strategy across BTC, ETH, and SOL without rewriting it.
Universe: running on more than one pair
Every strategy can declare a universe: the set of pairs it trades. The editor shows this as a row of checkboxes above the actions.
- Leave it empty (or pick one pair) and the strategy is single-pair. You choose the pair when you backtest or go live.
- Pick two or more and the strategy goes multi-pair on a shared cash pool. One pool of capital scans every pair on a unified timeline, and whichever pair's signal fires first claims a free slot and the cash. It's first-come, not split evenly: a selective strategy can keep its full firepower idle and deploy it wherever the best setup appears.
max_open_positions caps the total open positions across all pairs. An optional Max Per Pair caps how many positions any single pair can hold at once, so one pair can't hog the whole pool. In TOML it's a [universe] block:
[universe]
pairs = ["BTC_USDC", "ETH_USDC", "BNB_USDC"]
max_per_pair = 1 # at most one position per pair; max_open_positions still bounds the total
The Max Per Pair field appears once you select two or more pairs. Multi-pair is first-class for every strategy, not a separate strategy type.
Eviction: capital velocity
In a shared pool, a long-held losing position squats a slot. If the cap is full, a fresh signal on a fast-moving pair can't open — the dead bag starves it. Eviction fixes that: when a new open is blocked by max_open_positions, the engine frees a slot by closing an existing position.
The editor's Eviction section (shown once you pick two or more pairs) has a policy and a minimum hold:
[eviction]
policy = "worst_laggard"
min_hold = "2d"
A position must be held at least min_hold (2d, 12h, 45m, …) before it can be evicted, so fresh positions are protected. When a slot is contended, the policy chooses the victim:
- worst_laggard (recommended) — the most underwater position per day held. Targets old, going-nowhere capital.
- oldest — the longest-held position, regardless of P&L.
- smallest_loss — the position closest to break-even. It books the least, but tends to keep deep bags and cycle out healthy positions, so it often works against the goal.
- none (default) — no eviction; a blocked open is simply skipped (first-come).
The victim is closed at market on its own pair and books a realised loss. That's the point: dead capital is freed and redeployed into whatever fires next. It also makes results honest — a strategy that "never loses" because losses sit as open bags will show those losses once eviction closes them. Eviction only ever frees a global slot; a per-pair cap (max_per_pair) is never preempted. Single-pair strategies are unaffected.
Cross-asset entry filter
Alts don't move in a vacuum. A laggy coin often follows Bitcoin's lead with a small delay, so opening into a position while BTC is rolling over is a good way to catch a falling knife. The cross-asset entry filter gates your entries on a reference pair's trend: only open when, say, BTC's moving average is rising.
Turn on Cross-asset entry filter (it works for single- and multi-pair strategies alike) and pick:
- Reference pair — the asset whose momentum decides the gate, e.g.
BTC_USDC. It may even be the pair you're trading (a self-momentum filter). - MA type —
EMA(fast, the primary choice) orHMA(smoother, still low-lag). - MA span and Timeframe — e.g. a 9-period EMA on the 1h reference candles.
- Condition:
- rising — the reference MA is higher than the previous bar.
- velocity — the MA is rising at least a threshold per bar (e.g.
0.0002= 0.02%/bar), so a barely-drifting reference doesn't count as a trend. - price_above — the reference price is above its own MA.
[entry_filter]
ref_pair = "BTC_USDC"
ma_type = "ema"
span = 20
timeframe = "1h"
condition = "rising"
Two rules make the filter safe and predictable:
- It gates opens only.
open_longandopen_shortare blocked while the gate is shut. DCA buys, sells and covers are never gated — once you're in a position, your exits always run, so capital is never trapped behind a closed gate. - No look-ahead. The gate at any moment uses only the reference bar that has fully closed by then, aligned by timestamp (the reference pair has its own candles and gaps). During warm-up — before there are enough closed reference bars to compute the MA — the gate stays shut. Backtest, paper and live all compute the gate the same way, so results match.
A strategy without an [entry_filter] block behaves exactly as before. When the filter is on, the reference pair's data must be available (it's fetched and ingested alongside your trading pair); if it ever isn't, the engine surfaces the problem and blocks new opens rather than trading blind.
In practice the filter trades some upside in a raging bull (it sits out dips that recover) for much calmer drawdowns and far better behaviour in a downtrend — a "rising BTC EMA" gate on a dip-buyer turned a −34% 2022 bear-market run into a small gain while cutting max drawdown from 38% to 7%.
Allocation: signals or rotation
The editor's Allocation section decides how capital moves across the universe:
- Signals (the default) runs the per-pair triggers you build in the Actions section.
- Rotation ignores triggers and instead ranks the universe by momentum, holding the single strongest pair. This is GEM / dual momentum, covered in GEM / Dual Momentum.
Switching Allocation to Rotation hides the Actions section and reveals the momentum settings. Both kinds of strategy live in this one editor and save to the same [universe] / [allocation] format.
Indicators Reference
Triggers compare indicators to values or to other indicators. Each indicator takes a period and runs on a timeframe you choose. On a 1-minute base, an indicator on the 1h timeframe uses hourly candles built from the 1m data.
Available indicators
| Name | TOML key | What it measures |
|---|---|---|
| Simple Moving Average | sma_N | Average close over N periods |
| Exponential Moving Average | ema_N | Weighted average, more recent prices count more |
| Relative Strength Index | rsi_N | Momentum, 0–100, overbought/oversold |
| Bollinger Bands | bb_* | Volatility bands around a moving average |
| MACD | macd | Trend and momentum from two EMAs |
| On-Balance Volume | obv | Volume flow direction |
| OBV SMA | obv_sma_N | Smoothed OBV |
| TTM Trend | ttm_trend | Trend persistence |
| Stochastic RSI | stoch_rsi | RSI run through a stochastic oscillator |
| Rate of Change | roc_N | Momentum as a percentage move over N periods |
| Average True Range | atr_N | Volatility in price terms |
| Volume SMA | vol_sma_N | Smoothed volume |
| Price | price | The raw close price |
Reading a key
ema_21 is a 21-period EMA. On the 1h timeframe that's 21 hours. roc_90 is the percentage change over the last 90 periods, which is the momentum measure the GEM rotation strategy ranks on.
Operators
| Operator | Fires when |
|---|---|
cross_above | The indicator crosses up through the target |
cross_below | The indicator crosses down through the target |
greater_than | The indicator is above the target |
less_than | The indicator is below the target |
equal | The indicator equals the target |
cross_above and cross_below fire once on the crossing. greater_than and less_than are true for as long as the condition holds, so pair them with a max_count if you only want them to fire once.
GEM / Dual Momentum
Most strategies in Botmarley trade a single pair on triggers. A rotation strategy is different: it watches a basket of assets, ranks them by momentum, and holds the strongest one. When nothing is trending up, it sits in cash.
This is the idea behind Gary Antonacci's GEM (Global Equities Momentum), adapted to crypto. It's called dual momentum because it uses two rules together.
The two rules
Relative momentum. Each rebalance, measure how much each asset has moved over a lookback window, and hold the one that moved the most. You're always in the strongest horse.
Absolute momentum. Before committing, check that the winner is actually rising. If even the best asset in the basket is falling, hold cash instead. This is the rule that keeps you out of broad downturns, where every asset is red and "the best of a bad bunch" still loses money.
Together: hold the strongest asset, but only if it's genuinely going up.
Defining one
The quickest way is the + New GEM button on the Strategies page. It opens the normal strategy editor with Allocation already set to Rotation: pick your universe of pairs, set the momentum settings, and save. There's no separate GEM editor; a rotation strategy is just a strategy whose allocation is rotation.
In TOML, that's a [universe] block plus an [allocation] block with mode = "rotation", and no [[actions]]:
[meta]
name = "GEM Dual Momentum"
description = "Rank BTC/ETH/SOL, hold the strongest, sit in cash when none is trending up."
[universe]
# Assets to rank and rotate between.
pairs = ["BTC_USDC", "ETH_USDC", "SOL_USDC"]
max_open_positions = 1
[allocation]
mode = "rotation"
# Momentum lookback in 1m candles. 1440 = one day.
momentum_period = 1440
# Rebalance cadence in 1m candles. 1440 = once per day ("lazy").
rebalance_every = 1440
# Absolute-momentum gate, as a percentage. The winner must beat this or
# the strategy holds cash. 0.0 is classic GEM.
min_momentum = 0.0
The settings
| Field | What it does |
|---|---|
pairs (in [universe]) | The assets to rank. Two or more. |
momentum_period | Lookback for the momentum measure, in 1m candles. Bigger = slower, less twitchy. |
rebalance_every | How often to re-rank and possibly switch, in 1m candles. |
min_momentum | The absolute gate, in percent. Raise it to demand stronger trends before buying in. |
Momentum is the rate of change over momentum_period: how far the price has moved as a percentage. The same measure the rest of Botmarley calls ROC.
How it trades
At each rebalance point, Botmarley ranks the universe by momentum. If the top asset clears the min_momentum gate and it's not what you already hold, it sells the current holding and buys the new winner. If nothing clears the gate, it sells to cash and waits. Between rebalances it does nothing, which is the point. GEM is a low-turnover, "lazy" strategy. A daily rebalance on crypto is a reasonable starting point.
Backtest it first
Rotation strategies backtest the same way as any other. Run one across a couple of different periods before trusting it. GEM on equities has historically delivered steady single-digit-to-low-double-digit annual returns with shallower drawdowns than buy-and-hold, but crypto is a different, far more volatile market. Treat the equity numbers as inspiration, not a forecast, and let your own backtests tell you what to expect here.
Status
Rotation strategies run in backtesting today. Live multi-asset execution is on the roadmap.
Backtesting
A backtest runs your strategy against historical candles and reports what would have happened. It's the cheapest way to find out whether an idea has legs before you put money behind it.
Running one
From any strategy, choose Backtest, then set:
- Pair to test against (the strategy itself is pair-agnostic).
- Date range within the data you've downloaded.
- Starting capital in USD.
Run it and you get PnL, win rate, drawdown, an equity curve, and the full list of trades with the trigger that caused each one.
How close is it to live trading?
Botmarley works hard to make backtest results match live behaviour, because a backtest you can't trust is worse than none. The same evaluation and portfolio code runs in both, and the backtest executor models the things that move real fills:
- Fees are deducted per trade, not bolted on at the end. The default is 0.1% (Binance spot taker).
- Lot-size filters (minimum quantity and step size) match the real exchange, so a backtest can't fill an order the exchange would reject.
- Cross-pair pricing is handled, so a pair quoted in something other than USD (for example an ETH-quoted pair) values correctly.
- Slippage is off by default, so fills land at the candle close. You can switch it on with the
BACKTEST_SLIPPAGE_BPSenvironment variable when you want a more conservative result.
What a backtest can't tell you
It uses 1-minute candles, so it doesn't see what happened inside each minute. It assumes your order filled at the modelled price. And a strategy tuned to look perfect on past data often falls apart on new data. Test across several periods and several pairs, and treat a single great run with suspicion.
Live Trading
Live trading runs a strategy against the real market and places real orders. The decision logic is identical to backtesting; the difference is that orders go to the exchange.
Before you start
- Add your exchange API keys in settings. Use keys scoped to trading only, never withdrawal.
- Test the strategy in backtesting first, across more than one period.
- Start with a small amount.
Exchanges
| Exchange | Candles | Live orders |
|---|---|---|
| Binance | Yes | Yes |
| Kraken | Yes | Yes |
Both run through the same trading engine. The strategy doesn't know or care which exchange it's on, so the decisions are the same either way; only the order placement differs.
Running a session
Start a session from a strategy. You get a live candlestick chart with trade markers, the current strategy state, and controls to pause and resume. Pausing stops new entries while leaving open positions alone.
Notifications
Connect Telegram in settings to get a message when a trade fires or a session changes state. Handy when you're not watching the screen.
Routing through a tunnel
If your exchange account is locked to a fixed IP, Botmarley can send its API calls through an SSH SOCKS5 tunnel so they always leave from the same address. See Production Deployment for how that's set up.
Production Deployment
Running Botmarley for real means three things on top of the basic install: a proper database, a way to reach it over HTTPS, and keeping it running across reboots. Optionally, you route exchange calls through a fixed IP.
Database
Don't run the development database in production. Point Botmarley at a real PostgreSQL instance with the DATABASE_URL environment variable:
DATABASE_URL="postgresql://user:password@host:5432/botmarley"
Set it in your service definition (below) so it survives restarts.
Keep it running with systemd
A minimal unit file:
[Unit]
Description=Botmarley
After=network.target
[Service]
ExecStart=/home/youruser/botmarley/server
WorkingDirectory=/home/youruser/botmarley
Environment=DATABASE_URL=postgresql://user:password@host:5432/botmarley
Restart=always
RestartSec=5
[Install]
WantedBy=multi-user.target
sudo systemctl enable --now botmarley
Restart=always brings it back if it crashes, and enable brings it back after a reboot.
Expose it over HTTPS with Cloudflare Tunnel
You don't want the trading UI on a bare port open to the internet. A Cloudflare Tunnel gives you HTTPS on a real hostname without opening any inbound ports. The bot listens on localhost; cloudflared connects out to Cloudflare and forwards traffic in.
The reference setup runs two services behind one tunnel:
| Public hostname | Forwards to |
|---|---|
botmarley.lipinski.work | http://localhost:5000 (the bot) |
keymarley.lipinski.work | http://localhost:8000 (the licence server) |
In Cloudflare's dashboard, add a public hostname for each, with the service pointing at the local port. That's the whole ingress. Because the bot only listens on localhost, nothing is reachable except through the tunnel.
If you later move a service to a different host, you change the tunnel target in Cloudflare. Botmarley reads its endpoints from configuration, so nothing needs recompiling.
Route exchange calls through a fixed IP
Some exchange accounts are locked to a fixed IP for API access. Botmarley can send its exchange requests through an SSH SOCKS5 tunnel so they always leave from the same address.
Configure the SSH proxy in settings (host, port, user). Botmarley opens the tunnel, watches it, and reopens it if it drops. Once it's set, exchange calls route through that host. The reference deployment tunnels through a small always-on box and points exchange traffic at it.
Updating
Replace the binary and restart the service:
sudo systemctl restart botmarley
Your data in ~/.botmarley and your database are untouched by an update.
Licensing & KeyMarley
Botmarley checks a licence before it unlocks. The check is done by a small companion service called KeyMarley, and it's built so that a network blip never locks you out of your own bot.
How the check works
- You enter your licence key on the settings page.
- Botmarley sends it to the KeyMarley server, which returns a signed response.
- Botmarley verifies the signature with a built-in public key and caches the result on disk.
- It rechecks on boot and every 24 hours.
The signature uses Ed25519. The private signing key never lives on any deployed machine, so a signed response can't be forged even if someone has the binary.
The grace period
If the licence server is briefly unreachable, Botmarley falls back to the cached response and keeps running for a grace window of 48 hours. So a short outage on the licence server, or a momentary network drop, doesn't stop your bot. After the grace window with no successful check, the licence is treated as invalid.
The cache stores only the signed date, the signature, and a hash of your key. Your raw key is never written to disk.
Pointing at your own KeyMarley
By default Botmarley talks to the hosted licence server at https://keymarley.lipinski.work. If you run your own, override the endpoint with an environment variable:
KEYMARLEY_VALIDATE_URL="https://your-host.example.com/api/validate"
Because the URL is read from configuration, you can move the licence server to another host without rebuilding Botmarley.
Running KeyMarley yourself
KeyMarley is a small Axum service backed by SQLite. It listens on a local port (8000 in the reference setup) and is exposed over HTTPS through the same Cloudflare Tunnel as the bot. Keep it always-on with systemd the same way as the main service, so the licence check is there whenever a bot asks.