"""Strategy cookbook: mean reversion with a stop. A drop-in strategy.py 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.py and keep dry_run = true in config.toml until the log shows what you expect. decide(market, book, state) gets what the starter bot's strategy.py gets (prices, the wallet's balances, and state kept in bot-state.json) and returns a list of Order. bot.py cuts every order to your limits. An example to learn from, not advice: a price that keeps falling hits the stop, not the mean. """ import time from dataclasses import dataclass @dataclass class Order: sell: str # the token to sell, e.g. "ETH" buy: str # the token to buy, e.g. "USDG" amount_usd: float # how many dollars of `sell` to trade reason: str = "" # why: shown in the console and bot-log.jsonl WINDOW = 30 # ticks in the rolling mean (with interval_s = 60, the last 30 minutes) DIP = 0.02 # buy when ETH is 2% under the mean: a deeper dip buys less often STOP = 0.03 # sell when ETH is 3% under the price it bought at BUY_USD = 10 # dollars of USDG to spend on each dip MAX_GAP_S = 3600 # a tick more than an hour after the last one (the bot was stopped) starts the mean again def decide(market, book, state): s = state.setdefault("mean_reversion", {}) # this strategy's own corner of state price = market.get("ETH") if price is None: return [] # no ETH price this tick: wait for the next one now = time.time() if now - s.get("at", now) > MAX_GAP_S: s["prices"] = [] # stopped for a while: start the mean again (a position stays) s["at"] = now prices = s.setdefault("prices", []) mean = sum(prices) / WINDOW if len(prices) == WINDOW else None # the mean of the ticks before this one prices.append(price) del prices[:-WINDOW] # keep only the last WINDOW prices held = s.get("held") # what it bought: {"units": ETH, "price": dollars per ETH} if held: if price <= held["price"] * (1 - STOP): s["held"], s["wait"] = None, True # stopped out: no new dip until ETH is back at the mean return [sell(held, price, f"stop: ETH ${price:,.2f} is {1 - price / held['price']:.1%} under " f"the ${held['price']:,.2f} it bought at")] if mean is not None and price >= mean: s["held"] = None return [sell(held, price, f"back at the mean: ETH ${price:,.2f}, the mean ${mean:,.2f}")] return [] # holding: neither the mean nor the stop yet if mean is None: return [] # still collecting prices if s.get("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 usd_of(book, "USDG") < BUY_USD: return [] # not enough USDG: no position is recorded s["held"] = {"units": BUY_USD / price, "price": price} return [Order(sell="USDG", buy="ETH", amount_usd=BUY_USD, reason=f"dip: ETH ${price:,.2f} is {1 - price / mean:.1%} under its {WINDOW}-tick mean ${mean:,.2f}")] def sell(held, price, why): """Sell what the dip bought, at today's price: bot.py cuts it to what the wallet holds.""" return Order(sell="ETH", buy="USDG", amount_usd=held["units"] * price, reason=why) def usd_of(book, token): """The wallet's holding of `token` in dollars (0 when it holds none, or the token has no price).""" return (book["balances"].get(token) or {}).get("usd") or 0