Strategy DSL v1
A strategy is a JSON document with "dsl": 1, validated by the published JSON Schema. One token (universe.mint, optional pair_pool), a timeframe (1m, 5m, 15m or 1h: both the candle size and how often it's evaluated), a simulated budget_usd (default 100), an ordered list of up to 10 rules and a risk block.
Each rule is when (1–8 conditions joined by all or any) → then (one action), with optional once and cooldown. Conditions: price_change_pct, price, mcap_usd, liquidity_usd, volume_usd, drawdown_from_high_pct, position_open, position_pnl_pct, time_in_position, budget_left_usd. Actions: buy_usd, buy_pct_of_budget (of the starting budget), sell_pct_of_position, sell_all. Windows are multiples of the timeframe, up to 7 days. Risk: stop_loss_pct, take_profit_pct, trailing_stop_pct, max_position_usd, max_buys (per position).
Anything outside this set is unsupported. The compiler (Claude) must refuse and name the part it can't express: indicators, other assets, clock times, resting orders, shorting, leverage, sentiment, wallets. It never approximates silently. The plain-language rules you see are rendered from the DSL itself, so the text and the rules that run can't disagree.
Engine (dr-engine/1)
- Candles are walked in order. For candle i: a pending order fills at the open of i; then, on the close of i, the engine decides.
- Risk first: with a position open, if position P&L ≤ −stop loss, or the close is the trailing-stop distance below the highest close since entry, or P&L ≥ take profit, the decision is "sell everything". Stops are checked on closes, not intrabar lows, and fill at the next open: a gap down fills below the stop.
- Otherwise the rules in order: the first rule whose conditions hold and whose action can execute wins. A rule is skipped when it's used up (
once), cooling down, a sell with no position, or a buy that the caps (max_buys,max_position_usd, cash left) clip below $1. - The decision fills at the open of candle i+1. Conditions only read candles ≤ i; the test suite runs the engine on a series that throws if anything after i is read, and checks that changing future candles never changes past signals.
- Windows that reach before the first candle are false: the first part of a backtest is warm-up, as long as the longest window.
- Same DSL + same candles, snapshots and cost inputs give a byte-identical result. Every result carries
dsl_hash,data_hashandresult_hash(SHA-256 of its canonical JSON), andPOST /api/v1/backtestwith the sametoandsol_usdreproduces it.
Stats: total PnL (equity marked at the last close, an open position not sold), buy-and-hold over the same window with the same entry costs, max drawdown on the candle-by-candle equity, win rate over closed positions, exposure (share of candles holding a position), number of fills. No projected or annualized returns, ever.
Cost model
Every simulated trade pays three things, all shown on each fill:
- Network: 0.0002 SOL per trade (base + priority), converted at the SOL/USD price of the run. Configurable per strategy (
costs.network_fee_sol). - DEX fee by venue: raydium 0.25%, pumpswap 0.3%, orca 0.3%, meteora 1%, pumpfun 1%, launchlab 1%; any other venue 1%. Configurable (
costs.dex_fee_pct). - Slippage from a constant-product pool holding y = L/2 of quote value, L being the pool's liquidity in USD. A buy of a USD (after fees) receives a/p × y/(y+a) tokens; a sale of tokens worth g receives g × y/(y+g).
Liquidity at a past candle is estimated from the nearest DexScreener snapshot at or before it (the earliest one if there's none before): L(t) = L_snap × √(p(t) / p_snap), which is exact for a constant-product pool when nobody adds or removes liquidity in between. Market cap scales linearly with price from the snapshot. Snapshots are taken when a token is first used and every 5 minutes while a strategy on it runs, so paper trading reads real liquidity and older backtests read an estimate.
Data sources and refresh
- Candles: GeckoTerminal OHLCV (USD prices of the token, USD volume) for the token's most liquid pool on DexScreener, at the strategy's timeframe. One Durable Object paces every request under the public API's limit (one call every 2.5 s). Paper series refresh as soon as a new candle has closed and settled: about every minute for 1m/5m strategies and every 5 minutes or less for 15m/1h.
- Settling: a candle is stored 2 minutes after it closes and never overwritten, so a past range reads the same forever and paper trading and backtests read the same store.
- Liquidity, market cap, pool discovery: DexScreener. A token is tradable when its best pool holds ≥ $5,000.
- Window: the last 30 days, or since the pool's first candle when it's younger. Results show exactly which range was used.
Limits of the model
- Lookahead: none by construction (above). One caveat: a candle's close is "known" at the candle's end, but in reality there's a short lag before you could act on it. Fills at the next open absorb most of it.
- Slippage: the constant-product estimate ignores concentrated-liquidity ranges, routing across pools, MEV and other traders in the same candle. Small orders in deep pools are close; large orders in thin pools are optimistic or pessimistic depending on the venue's curve. Real trades get a live Jupiter quote instead.
- Data gaps: GeckoTerminal omits intervals without trades. Inside a fetched range a missing candle is filled with the previous close and zero volume, and counted. A range that couldn't be fetched is reported as a gap on the results page.
- Survivorship: you can only pick tokens that still trade today, with a pool that still has liquidity. Tokens that went to zero aren't there to test against, which flatters any strategy that buys dips on survivors.
- Paper is not real: a paper fill always executes at the modeled price. Real orders can fail, land later, or fill worse.
The paper record
"Start dry run" activates a version. The cron evaluates it with the same engine and cost model on each newly closed candle (every minute for 1m/5m strategies, every 5 minutes for 15m/1h), and every signal and simulated fill is appended to the record: strategy, version, sequence, candle time, rule, side, price, size, costs and running equity. Rows are never updated or deleted; the database refuses it. Editing a running strategy creates a new version with its own record and a fresh budget. The SOL/USD price used for network fees is the one at the time of the fill.
Merkle spec (DR1)
- Leaf: SHA-256 of the row's canonical JSON (keys sorted, no whitespace; every value is a string or an integer). Fields:
spec, strategy_id, version, seq, dsl_hash, kind, t, candle_t, rule, reason, side, price, usd, pct, tokens, fee_usd, slippage_usd, network_usd, equity_usd, recorded_at.dsl_hashbinds each row to the exact rules of its version. - Order: all rows recorded on one UTC day, sorted by strategy_id (bytewise), version, seq.
- Node: SHA-256("DR1/node" ‖ min(a,b) ‖ max(a,b)), bytewise order, so proofs carry no left/right bits. An odd level pairs its last node with itself. A day with no rows has the root SHA-256("DR1/empty").
- Publication: at 00:10 UTC, one SPL Memo from the oracle wallet 6R7N…YEVZ: dr1:root:<YYYY-MM-DD>:<root hex>:<leaf count>. The wallet only signs memo transactions; the code re-parses each message and refuses anything else before signing.
- Verify: in the browser, or with the zero-dependency scripts verify.mjs (Node) and verify.py (Python), which fetch a proof, recompute the leaf and root, and read the memo from a Solana RPC.
Test vectors
Three leaves and their root. Your implementation should reproduce these hashes exactly.
leaf 0
{"candle_t":1789999800,"dsl_hash":"9f2c2a1b4bd3c0d4ffb2b4d3a7c1e0f5b6a9d8c7e6f5a4b3c2d1e0f9a8b7c6d5","equity_usd":"100","fee_usd":"0","kind":"signal","network_usd":"0","pct":"0","price":"0.00214","reason":"rule","recorded_at":1790000160,"rule":1,"seq":1,"side":"buy","slippage_usd":"0","spec":"dr1","strategy_id":"s_vector2345","t":1790000100,"tokens":"0","usd":"25","version":1}sha256 = d0cd4a9bf9ef9f9daf0c81867a5fdec10986eb0df44228f6fa449b0fb37c8827
leaf 1
{"candle_t":1790000100,"dsl_hash":"9f2c2a1b4bd3c0d4ffb2b4d3a7c1e0f5b6a9d8c7e6f5a4b3c2d1e0f9a8b7c6d5","equity_usd":"99.9124271488","fee_usd":"0.06245","kind":"fill","network_usd":"0.02","pct":"0","price":"0.00213","reason":"rule","recorded_at":1790000460,"rule":1,"seq":2,"side":"buy","slippage_usd":"0.0031228512","spec":"dr1","strategy_id":"s_vector2345","t":1790000100,"tokens":"11695.9230932","usd":"25","version":1}sha256 = 331afa2510e2351e09b91260b6a12afffc65c12931c858e3f16ec5b27cc35487
leaf 2
{"candle_t":1790003400,"dsl_hash":"9f2c2a1b4bd3c0d4ffb2b4d3a7c1e0f5b6a9d8c7e6f5a4b3c2d1e0f9a8b7c6d5","equity_usd":"104.356","fee_usd":"0","kind":"signal","network_usd":"0","pct":"100","price":"0.00251","reason":"take_profit","recorded_at":1790003760,"rule":0,"seq":3,"side":"sell","slippage_usd":"0","spec":"dr1","strategy_id":"s_vector2345","t":1790003700,"tokens":"0","usd":"0","version":1}sha256 = 71dd2eba28ab36f2c00b8b860a168eccb644ec17ca04e8ede3b8baf26c80dd34
root = fd5ef3e1c89d1da3d0cb6ff1f0fedc4671d185bc33fbb578fa74b8ccf5a5a6aa
proof(leaf 0) = [331afa2510e2351e09b91260b6a12afffc65c12931c858e3f16ec5b27cc35487, 077df11d124316ac8e95c4c77f5700a7a69812d58cbee051a6dfb4911edc0086]