/** * Strategy cookbook: mean reversion with a stop. A drop-in strategy.ts for the Orblivion starter bot. * * It keeps the last WINDOW ETH prices in state, one per tick, and their mean. When ETH falls DIP under that mean it buys * BUY_USD of ETH with USDG and remembers what it bought (state's `held`). It sells what it bought when ETH is back at the * mean, or when ETH falls STOP under the price it paid (the stop). After a stop it waits for ETH to reach the mean again * before it buys another dip. It holds one position at a time. * * To use it, copy this file over the starter bot's strategy.ts and keep "dry_run": true in config.json until the log * shows what you expect. decide(market, book, state) gets what the starter bot's strategy.ts gets (prices, the wallet's * balances, and state kept in bot-state.json) and returns a list of orders. bot.ts cuts every order to your limits. * * An example to learn from, not advice: a price that keeps falling hits the stop, not the mean. */ export type Market = Record export type Holding = { units: number | null; usd: number | null } export type Book = { balances: Record; totalUsd: number } export type State = Record export type Order = { sell: string // the token to sell, e.g. 'ETH' buy: string // the token to buy, e.g. 'USDG' amountUsd: number // how many dollars of `sell` to trade reason?: string // why: shown in the console and bot-log.jsonl } const WINDOW = 30 // ticks in the rolling mean (with interval_s = 60, the last 30 minutes) const DIP = 0.02 // buy when ETH is 2% under the mean: a deeper dip buys less often const STOP = 0.03 // sell when ETH is 3% under the price it bought at const BUY_USD = 10 // dollars of USDG to spend on each dip const MAX_GAP_S = 3600 // a tick more than an hour after the last one (the bot was stopped) starts the mean again type Held = { units: number; price: number } // what it bought: ETH, and dollars per ETH export function decide(market: Market, book: Book, state: State): Order[] { const s = (state.meanReversion ??= {}) // this strategy's own corner of state const price = market.ETH if (price === undefined) return [] // no ETH price this tick: wait for the next one const now = Date.now() / 1000 if (now - (s.at ?? now) > MAX_GAP_S) s.prices = [] // stopped for a while: start the mean again (a position stays) s.at = now const prices: number[] = (s.prices ??= []) const mean = prices.length === WINDOW ? sum(prices) / WINDOW : null // the mean of the ticks before this one prices.push(price) if (prices.length > WINDOW) prices.splice(0, prices.length - WINDOW) // keep only the last WINDOW prices const held: Held | null = s.held ?? null if (held) { if (price <= held.price * (1 - STOP)) { s.held = null s.wait = true // stopped out: no new dip until ETH is back at the mean return [sell(held, price, `stop: ETH $${price.toFixed(2)} is ${pct(1 - price / held.price)} under the $${held.price.toFixed(2)} it bought at`)] } if (mean !== null && price >= mean) { s.held = null return [sell(held, price, `back at the mean: ETH $${price.toFixed(2)}, the mean $${mean.toFixed(2)}`)] } return [] // holding: neither the mean nor the stop yet } if (mean === null) return [] // still collecting prices if (s.wait) { if (price >= mean) s.wait = false // back at the mean after a stop: dips count again return [] } if (price > mean * (1 - DIP)) return [] // not down enough if (usdOf(book, 'USDG') < BUY_USD) return [] // not enough USDG: no position is recorded s.held = { units: BUY_USD / price, price } const why = `dip: ETH $${price.toFixed(2)} is ${pct(1 - price / mean)} under its ${WINDOW}-tick mean $${mean.toFixed(2)}` return [{ sell: 'USDG', buy: 'ETH', amountUsd: BUY_USD, reason: why }] } /** Sell what the dip bought, at today's price: bot.ts cuts it to what the wallet holds. */ const sell = (held: Held, price: number, why: string): Order => ({ sell: 'ETH', buy: 'USDG', amountUsd: held.units * price, reason: why }) /** The wallet's holding of `token` in dollars (0 when it holds none, or the token has no price). */ const usdOf = (book: Book, token: string) => book.balances[token]?.usd ?? 0 const sum = (xs: number[]) => xs.reduce((a, b) => a + b, 0) const pct = (x: number) => `${(x * 100).toFixed(1)}%`