From e4ee6a1f6c93885c643b8c7a17f0562b9ecc02d9 Mon Sep 17 00:00:00 2001 From: WGB5445 <919603023@qq.com> Date: Tue, 14 Apr 2026 15:19:14 +0800 Subject: [PATCH 1/9] Add single-file market maker bot example --- examples/write/market_maker_bot.py | 487 +++++++++++++++++++++++++++++ 1 file changed, 487 insertions(+) create mode 100644 examples/write/market_maker_bot.py diff --git a/examples/write/market_maker_bot.py b/examples/write/market_maker_bot.py new file mode 100644 index 0000000..bf5ffcf --- /dev/null +++ b/examples/write/market_maker_bot.py @@ -0,0 +1,487 @@ +from __future__ import annotations + +import argparse +import asyncio +import os +from dataclasses import dataclass + +from aptos_sdk.account import Account +from aptos_sdk.ed25519 import PrivateKey + +from decibel import ( + NAMED_CONFIGS, + BaseSDKOptions, + DecibelWriteDex, + GasPriceManager, + PlaceOrderSuccess, + TimeInForce, + amount_to_chain_units, + round_to_tick_size, + round_to_valid_order_size, +) +from decibel.read import DecibelReadDex, PerpMarket + + +@dataclass(frozen=True) +class MMSettings: + market_name: str = "BTC/USD" + spread: float = 0.001 + order_size: float = 0.001 + max_inventory: float = 0.005 + skew_per_unit: float = 0.0001 + max_margin_usage: float = 0.5 + refresh_interval_s: float = 20.0 + cooldown_s: float = 1.5 + cancel_resync_s: float = 8.0 + max_cycles: int = 0 + dry_run: bool = False + + +def _env_bool(name: str, default: bool = False) -> bool: + raw = os.getenv(name) + if raw is None: + return default + return raw.strip().lower() in {"1", "true", "yes", "y", "on"} + + +def _normalize_market_name(name: str) -> str: + return name.strip().replace("-", "/").upper() + + +def _resolve_market(markets: list[PerpMarket], requested_name: str) -> PerpMarket | None: + requested = _normalize_market_name(requested_name) + for market in markets: + if _normalize_market_name(market.market_name) == requested: + return market + return None + + +def _compute_quotes( + *, + mid: float, + inventory: float, + market: PerpMarket, + settings: MMSettings, +) -> tuple[float, float, float] | None: + tick_size = int(market.tick_size) + lot_size = int(market.lot_size) + min_size = int(market.min_size) + + if mid <= 0: + return None + + tick_human = tick_size / (10**market.px_decimals) + min_spread = tick_human / mid + if settings.spread < min_spread: + raise ValueError( + f"spread {settings.spread} is tighter than one tick ({min_spread:.8f}); " + "increase --spread", + ) + + if abs(inventory) >= settings.max_inventory: + return None + + valid_size = round_to_valid_order_size( + settings.order_size, + lot_size=lot_size, + sz_decimals=market.sz_decimals, + min_size=min_size, + ) + if valid_size <= 0: + return None + + half_spread = settings.spread / 2.0 + skew = inventory * settings.skew_per_unit + + raw_bid = mid * (1.0 - half_spread - skew) + raw_ask = mid * (1.0 + half_spread - skew) + + bid = round_to_tick_size( + raw_bid, + tick_size=tick_size, + px_decimals=market.px_decimals, + round_up=False, + ) + ask = round_to_tick_size( + raw_ask, + tick_size=tick_size, + px_decimals=market.px_decimals, + round_up=True, + ) + + if ask <= bid: + ask = round_to_tick_size( + bid + tick_human, + tick_size=tick_size, + px_decimals=market.px_decimals, + round_up=True, + ) + + return bid, ask, valid_size + + +async def _sync_state( + read: DecibelReadDex, + market: PerpMarket, + subaccount_addr: str, +) -> tuple[float | None, float, float, list[str]]: + overview_task = read.account_overview.get_by_addr(sub_addr=subaccount_addr) + positions_task = read.user_positions.get_by_addr(sub_addr=subaccount_addr, limit=100) + orders_task = read.user_open_orders.get_by_addr(sub_addr=subaccount_addr, limit=200) + prices_task = read.market_prices.get_all() + + overview, positions, open_orders, prices = await asyncio.gather( + overview_task, + positions_task, + orders_task, + prices_task, + ) + + inventory = 0.0 + for pos in positions: + if pos.market == market.market_addr: + inventory = pos.size + break + + market_order_ids = [ + order.order_id for order in open_orders.items if order.market == market.market_addr + ] + + mid: float | None = None + for price in prices: + if price.market == market.market_addr: + mid = price.mid_px or price.mark_px + break + + if mid is None: + try: + depth = await read.market_depth.get_by_name(market.market_name, limit=1) + if depth.bids and depth.asks: + mid = (depth.bids[0].price + depth.asks[0].price) / 2.0 + except Exception as exc: + print(f" warning: failed depth fallback for {market.market_name}: {exc}") + + return mid, inventory, overview.cross_margin_ratio, market_order_ids + + +async def _cancel_market_orders( + write: DecibelWriteDex, + market_name: str, + order_ids: list[str], + subaccount_addr: str, + dry_run: bool, +) -> tuple[int, int]: + cancelled = 0 + failed = 0 + for order_id in order_ids: + if dry_run: + print(f" [dry-run] would cancel {order_id}") + cancelled += 1 + continue + try: + await write.cancel_order( + order_id=order_id, + market_name=market_name, + subaccount_addr=subaccount_addr, + ) + cancelled += 1 + except Exception as exc: + print(f" cancel failed ({order_id}): {exc}") + failed += 1 + return cancelled, failed + + +async def _place_quote( + write: DecibelWriteDex | None, + *, + market: PerpMarket, + subaccount_addr: str, + is_buy: bool, + price: float, + size: float, + dry_run: bool, +) -> None: + side = "bid" if is_buy else "ask" + if dry_run: + print(f" [dry-run] would place {side}: {size} @ {price}") + return + if write is None: + raise RuntimeError("write client is required in live mode") + + result = await write.place_order( + market_name=market.market_name, + price=amount_to_chain_units(price, market.px_decimals), + size=amount_to_chain_units(size, market.sz_decimals), + is_buy=is_buy, + time_in_force=TimeInForce.PostOnly, + is_reduce_only=False, + subaccount_addr=subaccount_addr, + tick_size=market.tick_size, + ) + if isinstance(result, PlaceOrderSuccess): + print(f" {side} placed: {price} x {size} (tx={result.transaction_hash[:16]}...)") + else: + print(f" {side} failed: {result.error}") + + +async def _run_cycle( + cycle: int, + *, + read: DecibelReadDex, + write: DecibelWriteDex | None, + market: PerpMarket, + subaccount_addr: str, + settings: MMSettings, +) -> None: + mid, inventory, margin_usage, open_order_ids = await _sync_state(read, market, subaccount_addr) + print( + f"\n[cycle {cycle}] mid={mid if mid is not None else 'N/A'} " + f"inventory={inventory:+.6f} " + f"margin={margin_usage * 100:.2f}% open_orders={len(open_order_ids)}" + ) + + if margin_usage > settings.max_margin_usage: + print( + f" paused: margin {margin_usage * 100:.2f}% > {settings.max_margin_usage * 100:.2f}%" + ) + return + if mid is None: + print(" paused: no mid price available") + return + + quotes = _compute_quotes( + mid=mid, + inventory=inventory, + market=market, + settings=settings, + ) + if quotes is None: + print( + f" paused: inventory {inventory:+.6f} at/above max {settings.max_inventory}; " + "canceling resting orders only" + ) + if write is not None and open_order_ids: + await _cancel_market_orders( + write, + market_name=market.market_name, + order_ids=open_order_ids, + subaccount_addr=subaccount_addr, + dry_run=settings.dry_run, + ) + return + + bid, ask, size = quotes + print(f" quotes: bid={bid} ask={ask} size={size}") + + failed = 0 + if write is not None and open_order_ids: + cancelled, failed = await _cancel_market_orders( + write, + market_name=market.market_name, + order_ids=open_order_ids, + subaccount_addr=subaccount_addr, + dry_run=settings.dry_run, + ) + print(f" cancelled={cancelled} failed={failed}") + + if failed > 0: + await asyncio.sleep(settings.cancel_resync_s) + still_open = await read.user_open_orders.get_by_addr(sub_addr=subaccount_addr, limit=200) + market_still_open = [o for o in still_open.items if o.market == market.market_addr] + if market_still_open: + print(f" still {len(market_still_open)} open orders, skip this cycle") + return + + await _place_quote( + write, + market=market, + subaccount_addr=subaccount_addr, + is_buy=True, + price=bid, + size=size, + dry_run=settings.dry_run, + ) + await asyncio.sleep(settings.cooldown_s) + await _place_quote( + write, + market=market, + subaccount_addr=subaccount_addr, + is_buy=False, + price=ask, + size=size, + dry_run=settings.dry_run, + ) + + +def _parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description=( + "Single-file Decibel market maker bot: each cycle cancels existing market " + "orders and places a POST_ONLY bid/ask around mid price with inventory skew." + ), + ) + parser.add_argument( + "--network", + default=os.getenv("NETWORK", "testnet"), + choices=("testnet", "mainnet"), + help="Network profile from decibel.NAMED_CONFIGS", + ) + parser.add_argument( + "--market", + default=os.getenv("MARKET_NAME", "BTC/USD"), + help="Market symbol, e.g. BTC/USD", + ) + parser.add_argument("--spread", type=float, default=float(os.getenv("MM_SPREAD", "0.001"))) + parser.add_argument( + "--order-size", + type=float, + default=float(os.getenv("MM_ORDER_SIZE", "0.001")), + ) + parser.add_argument( + "--max-inventory", + type=float, + default=float(os.getenv("MM_MAX_INVENTORY", "0.005")), + ) + parser.add_argument( + "--skew-per-unit", + type=float, + default=float(os.getenv("MM_SKEW_PER_UNIT", "0.0001")), + ) + parser.add_argument( + "--max-margin-usage", + type=float, + default=float(os.getenv("MM_MAX_MARGIN", "0.5")), + help="Pause quoting when cross_margin_ratio exceeds this value", + ) + parser.add_argument( + "--refresh-interval", + type=float, + default=float(os.getenv("MM_REFRESH_S", "20")), + help="Seconds between cycles", + ) + parser.add_argument( + "--cooldown", + type=float, + default=float(os.getenv("MM_COOLDOWN_S", "1.5")), + help="Seconds between placing bid and ask", + ) + parser.add_argument( + "--cancel-resync", + type=float, + default=float(os.getenv("MM_CANCEL_RESYNC_S", "8")), + help="Sleep before re-checking open orders after cancel failures", + ) + parser.add_argument( + "--max-cycles", + type=int, + default=int(os.getenv("MAX_CYCLES", "0")), + help="Stop after N cycles (0 = run forever)", + ) + parser.add_argument( + "--dry-run", + action=argparse.BooleanOptionalAction, + default=_env_bool("DRY_RUN", False), + help="Simulate cancels/orders without sending transactions", + ) + return parser.parse_args() + + +async def main() -> int: + args = _parse_args() + + subaccount_addr = os.getenv("SUBACCOUNT_ADDRESS", "").strip() + node_api_key = os.getenv("APTOS_NODE_API_KEY", "").strip() or None + private_key_hex = os.getenv("PRIVATE_KEY", "").strip() + + if not subaccount_addr: + print("Error: SUBACCOUNT_ADDRESS is required") + return 1 + + dry_run = args.dry_run + if not private_key_hex: + print("PRIVATE_KEY missing, forcing dry-run mode") + dry_run = True + + settings = MMSettings( + market_name=args.market, + spread=args.spread, + order_size=args.order_size, + max_inventory=args.max_inventory, + skew_per_unit=args.skew_per_unit, + max_margin_usage=args.max_margin_usage, + refresh_interval_s=args.refresh_interval, + cooldown_s=args.cooldown, + cancel_resync_s=args.cancel_resync, + max_cycles=args.max_cycles, + dry_run=dry_run, + ) + + config = NAMED_CONFIGS[args.network] + read = DecibelReadDex(config, api_key=node_api_key) + + gas: GasPriceManager | None = None + write: DecibelWriteDex | None = None + try: + markets = await read.markets.get_all() + market = _resolve_market(markets, settings.market_name) + if market is None: + preview = ", ".join(m.market_name for m in markets[:8]) + print(f"Market '{settings.market_name}' not found. Sample: {preview}") + return 1 + + print(f"Starting MM bot on {market.market_name} ({args.network})") + print( + f" spread={settings.spread} order_size={settings.order_size} " + f"max_inventory={settings.max_inventory} skew_per_unit={settings.skew_per_unit}" + ) + print( + f" max_margin_usage={settings.max_margin_usage} " + f"refresh={settings.refresh_interval_s}s " + f"cooldown={settings.cooldown_s}s dry_run={settings.dry_run}" + ) + + if not settings.dry_run: + private_key = PrivateKey.from_hex(private_key_hex) + account = Account.load_key(private_key.hex()) + gas = GasPriceManager(config) + await gas.initialize() + write = DecibelWriteDex( + config, + account, + opts=BaseSDKOptions( + node_api_key=node_api_key, + gas_price_manager=gas, + skip_simulate=False, + no_fee_payer=True, + time_delta_ms=0, + ), + ) + + cycle = 1 + while True: + try: + await _run_cycle( + cycle, + read=read, + write=write, + market=market, + subaccount_addr=subaccount_addr, + settings=settings, + ) + except Exception as exc: + print(f" [cycle {cycle} error] {exc}") + + if settings.max_cycles > 0 and cycle >= settings.max_cycles: + break + cycle += 1 + await asyncio.sleep(settings.refresh_interval_s) + finally: + await read.ws.close() + if gas is not None: + await gas.destroy() + + return 0 + + +if __name__ == "__main__": + raise SystemExit(asyncio.run(main())) From d52a4f4f120a88e278fe114b16edf8b18be31fa3 Mon Sep 17 00:00:00 2001 From: WGB5445 <919603023@qq.com> Date: Tue, 14 Apr 2026 15:55:13 +0800 Subject: [PATCH 2/9] Address PR review feedback for market maker example --- examples/write/market_maker_bot.py | 56 +++++++++--- tests/test_market_maker_bot.py | 139 +++++++++++++++++++++++++++++ 2 files changed, 184 insertions(+), 11 deletions(-) create mode 100644 tests/test_market_maker_bot.py diff --git a/examples/write/market_maker_bot.py b/examples/write/market_maker_bot.py index bf5ffcf..f8e04d1 100644 --- a/examples/write/market_maker_bot.py +++ b/examples/write/market_maker_bot.py @@ -4,6 +4,7 @@ import asyncio import os from dataclasses import dataclass +from enum import StrEnum from aptos_sdk.account import Account from aptos_sdk.ed25519 import PrivateKey @@ -37,6 +38,21 @@ class MMSettings: dry_run: bool = False +class QuoteStatus(StrEnum): + OK = "ok" + PAUSE_NO_PRICE = "pause_no_price" + PAUSE_INVENTORY_LIMIT = "pause_inventory_limit" + PAUSE_SIZE_INVALID = "pause_size_invalid" + + +@dataclass(frozen=True) +class QuoteDecision: + status: QuoteStatus + bid: float | None = None + ask: float | None = None + size: float | None = None + + def _env_bool(name: str, default: bool = False) -> bool: raw = os.getenv(name) if raw is None: @@ -62,13 +78,13 @@ def _compute_quotes( inventory: float, market: PerpMarket, settings: MMSettings, -) -> tuple[float, float, float] | None: +) -> QuoteDecision: tick_size = int(market.tick_size) lot_size = int(market.lot_size) min_size = int(market.min_size) if mid <= 0: - return None + return QuoteDecision(status=QuoteStatus.PAUSE_NO_PRICE) tick_human = tick_size / (10**market.px_decimals) min_spread = tick_human / mid @@ -79,7 +95,7 @@ def _compute_quotes( ) if abs(inventory) >= settings.max_inventory: - return None + return QuoteDecision(status=QuoteStatus.PAUSE_INVENTORY_LIMIT) valid_size = round_to_valid_order_size( settings.order_size, @@ -88,7 +104,7 @@ def _compute_quotes( min_size=min_size, ) if valid_size <= 0: - return None + return QuoteDecision(status=QuoteStatus.PAUSE_SIZE_INVALID) half_spread = settings.spread / 2.0 skew = inventory * settings.skew_per_unit @@ -117,7 +133,12 @@ def _compute_quotes( round_up=True, ) - return bid, ask, valid_size + return QuoteDecision( + status=QuoteStatus.OK, + bid=bid, + ask=ask, + size=valid_size, + ) async def _sync_state( @@ -165,7 +186,7 @@ async def _sync_state( async def _cancel_market_orders( - write: DecibelWriteDex, + write: DecibelWriteDex | None, market_name: str, order_ids: list[str], subaccount_addr: str, @@ -178,6 +199,8 @@ async def _cancel_market_orders( print(f" [dry-run] would cancel {order_id}") cancelled += 1 continue + if write is None: + raise RuntimeError("write client is required when not in dry-run mode") try: await write.cancel_order( order_id=order_id, @@ -249,18 +272,18 @@ async def _run_cycle( print(" paused: no mid price available") return - quotes = _compute_quotes( + decision = _compute_quotes( mid=mid, inventory=inventory, market=market, settings=settings, ) - if quotes is None: + if decision.status is QuoteStatus.PAUSE_INVENTORY_LIMIT: print( f" paused: inventory {inventory:+.6f} at/above max {settings.max_inventory}; " "canceling resting orders only" ) - if write is not None and open_order_ids: + if (settings.dry_run or write is not None) and open_order_ids: await _cancel_market_orders( write, market_name=market.market_name, @@ -269,12 +292,20 @@ async def _run_cycle( dry_run=settings.dry_run, ) return + if decision.status is QuoteStatus.PAUSE_SIZE_INVALID: + raise ValueError("order size rounds to zero; adjust --order-size or market lot/min size") + if decision.status is QuoteStatus.PAUSE_NO_PRICE: + print(" paused: invalid mid price") + return + + if decision.bid is None or decision.ask is None or decision.size is None: + raise RuntimeError(f"unexpected quote decision: {decision.status}") - bid, ask, size = quotes + bid, ask, size = decision.bid, decision.ask, decision.size print(f" quotes: bid={bid} ask={ask} size={size}") failed = 0 - if write is not None and open_order_ids: + if (settings.dry_run or write is not None) and open_order_ids: cancelled, failed = await _cancel_market_orders( write, market_name=market.market_name, @@ -468,6 +499,9 @@ async def main() -> int: subaccount_addr=subaccount_addr, settings=settings, ) + except ValueError as exc: + print(f"fatal config error: {exc}") + return 2 except Exception as exc: print(f" [cycle {cycle} error] {exc}") diff --git a/tests/test_market_maker_bot.py b/tests/test_market_maker_bot.py new file mode 100644 index 0000000..76f3160 --- /dev/null +++ b/tests/test_market_maker_bot.py @@ -0,0 +1,139 @@ +from __future__ import annotations + +import argparse +import asyncio +import importlib.util +import sys +from pathlib import Path +from types import SimpleNamespace + +import pytest + + +def _load_market_maker_module(): + file_path = Path(__file__).resolve().parents[1] / "examples" / "write" / "market_maker_bot.py" + spec = importlib.util.spec_from_file_location("market_maker_bot", file_path) + if spec is None or spec.loader is None: + raise RuntimeError("failed to load market maker bot module") + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + + +def _fake_market(): + return SimpleNamespace( + market_name="BTC/USD", + market_addr="0xabc", + tick_size=100, + lot_size=1000, + min_size=100, + px_decimals=2, + sz_decimals=4, + ) + + +def test_cancel_market_orders_dry_run_without_write(capsys: pytest.CaptureFixture[str]) -> None: + mm = _load_market_maker_module() + cancelled, failed = asyncio.run( + mm._cancel_market_orders( + write=None, + market_name="BTC/USD", + order_ids=["1", "2"], + subaccount_addr="0xsub", + dry_run=True, + ) + ) + out = capsys.readouterr().out + assert "would cancel 1" in out + assert "would cancel 2" in out + assert cancelled == 2 + assert failed == 0 + + +def test_compute_quotes_size_invalid_status() -> None: + mm = _load_market_maker_module() + market = _fake_market() + settings = mm.MMSettings(order_size=0.0) + decision = mm._compute_quotes( + mid=100000.0, + inventory=0.0, + market=market, + settings=settings, + ) + assert decision.status is mm.QuoteStatus.PAUSE_SIZE_INVALID + + +def test_compute_quotes_inventory_limit_status() -> None: + mm = _load_market_maker_module() + market = _fake_market() + settings = mm.MMSettings(max_inventory=0.01, order_size=0.001) + decision = mm._compute_quotes( + mid=100000.0, + inventory=0.01, + market=market, + settings=settings, + ) + assert decision.status is mm.QuoteStatus.PAUSE_INVENTORY_LIMIT + + +def test_compute_quotes_spread_too_tight_raises() -> None: + mm = _load_market_maker_module() + market = _fake_market() + settings = mm.MMSettings(spread=0.000001) + with pytest.raises(ValueError): + mm._compute_quotes( + mid=100000.0, + inventory=0.0, + market=market, + settings=settings, + ) + + +def test_main_returns_nonzero_for_value_error(monkeypatch: pytest.MonkeyPatch) -> None: + mm = _load_market_maker_module() + market = _fake_market() + + class _FakeMarkets: + async def get_all(self): + return [market] + + class _FakeWs: + async def close(self): + return None + + class _FakeReadDex: + def __init__(self, config, api_key=None): + self.markets = _FakeMarkets() + self.ws = _FakeWs() + + async def _fake_run_cycle(*args, **kwargs): + raise ValueError("bad spread") + + monkeypatch.setattr( + mm, + "_parse_args", + lambda: argparse.Namespace( + network="testnet", + market="BTC/USD", + spread=0.001, + order_size=0.001, + max_inventory=0.005, + skew_per_unit=0.0001, + max_margin_usage=0.5, + refresh_interval=0.01, + cooldown=0.0, + cancel_resync=0.0, + max_cycles=1, + dry_run=True, + ), + ) + monkeypatch.setattr(mm, "DecibelReadDex", _FakeReadDex) + monkeypatch.setattr(mm, "_resolve_market", lambda markets, requested: market) + monkeypatch.setattr(mm, "_run_cycle", _fake_run_cycle) + monkeypatch.setenv("SUBACCOUNT_ADDRESS", "0xsub") + monkeypatch.delenv("PRIVATE_KEY", raising=False) + monkeypatch.delenv("APTOS_NODE_API_KEY", raising=False) + + exit_code = asyncio.run(mm.main()) + assert exit_code == 2 From cbb1fceda15b8e1ace67c5d8a0ef06b0e6e42858 Mon Sep 17 00:00:00 2001 From: WGB5445 <919603023@qq.com> Date: Tue, 14 Apr 2026 17:47:28 +0800 Subject: [PATCH 3/9] Fix MM example network choices and log format --- examples/write/market_maker_bot.py | 6 +++--- tests/test_market_maker_bot.py | 26 ++++++++++++++++++++++++++ 2 files changed, 29 insertions(+), 3 deletions(-) diff --git a/examples/write/market_maker_bot.py b/examples/write/market_maker_bot.py index f8e04d1..a72e090 100644 --- a/examples/write/market_maker_bot.py +++ b/examples/write/market_maker_bot.py @@ -226,7 +226,7 @@ async def _place_quote( ) -> None: side = "bid" if is_buy else "ask" if dry_run: - print(f" [dry-run] would place {side}: {size} @ {price}") + print(f" [dry-run] would place {side}: {price} x {size}") return if write is None: raise RuntimeError("write client is required in live mode") @@ -354,8 +354,8 @@ def _parse_args() -> argparse.Namespace: parser.add_argument( "--network", default=os.getenv("NETWORK", "testnet"), - choices=("testnet", "mainnet"), - help="Network profile from decibel.NAMED_CONFIGS", + choices=tuple(NAMED_CONFIGS), + help="Network profile key from decibel.NAMED_CONFIGS", ) parser.add_argument( "--market", diff --git a/tests/test_market_maker_bot.py b/tests/test_market_maker_bot.py index 76f3160..1f66ef6 100644 --- a/tests/test_market_maker_bot.py +++ b/tests/test_market_maker_bot.py @@ -90,6 +90,32 @@ def test_compute_quotes_spread_too_tight_raises() -> None: ) +def test_parse_args_accepts_named_config_network_key(monkeypatch: pytest.MonkeyPatch) -> None: + mm = _load_market_maker_module() + network_key = "local" if "local" in mm.NAMED_CONFIGS else next(iter(mm.NAMED_CONFIGS)) + monkeypatch.setattr(sys, "argv", ["market_maker_bot.py", "--network", network_key]) + args = mm._parse_args() + assert args.network == network_key + + +def test_place_quote_dry_run_uses_price_x_size(capsys: pytest.CaptureFixture[str]) -> None: + mm = _load_market_maker_module() + market = _fake_market() + asyncio.run( + mm._place_quote( + write=None, + market=market, + subaccount_addr="0xsub", + is_buy=True, + price=100.5, + size=0.002, + dry_run=True, + ) + ) + out = capsys.readouterr().out + assert "would place bid: 100.5 x 0.002" in out + + def test_main_returns_nonzero_for_value_error(monkeypatch: pytest.MonkeyPatch) -> None: mm = _load_market_maker_module() market = _fake_market() From b202bbc3b8b279b68fab6080592a0046f405b8e1 Mon Sep 17 00:00:00 2001 From: WGB5445 <919603023@qq.com> Date: Tue, 14 Apr 2026 18:00:46 +0800 Subject: [PATCH 4/9] Fix mid price fallback in MM sync state --- examples/write/market_maker_bot.py | 2 +- tests/test_market_maker_bot.py | 30 ++++++++++++++++++++++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/examples/write/market_maker_bot.py b/examples/write/market_maker_bot.py index a72e090..892bfc7 100644 --- a/examples/write/market_maker_bot.py +++ b/examples/write/market_maker_bot.py @@ -171,7 +171,7 @@ async def _sync_state( mid: float | None = None for price in prices: if price.market == market.market_addr: - mid = price.mid_px or price.mark_px + mid = price.mid_px break if mid is None: diff --git a/tests/test_market_maker_bot.py b/tests/test_market_maker_bot.py index 1f66ef6..b1602b7 100644 --- a/tests/test_market_maker_bot.py +++ b/tests/test_market_maker_bot.py @@ -116,6 +116,36 @@ def test_place_quote_dry_run_uses_price_x_size(capsys: pytest.CaptureFixture[str assert "would place bid: 100.5 x 0.002" in out +def test_sync_state_uses_mid_px_without_falsy_fallback() -> None: + mm = _load_market_maker_module() + market = _fake_market() + + class _FakeAccountOverview: + async def get_by_addr(self, sub_addr): + return SimpleNamespace(cross_margin_ratio=0.1) + + class _FakeUserPositions: + async def get_by_addr(self, sub_addr, limit): + return [SimpleNamespace(market=market.market_addr, size=0.0)] + + class _FakeUserOpenOrders: + async def get_by_addr(self, sub_addr, limit): + return SimpleNamespace(items=[]) + + class _FakeMarketPrices: + async def get_all(self): + return [SimpleNamespace(market=market.market_addr, mid_px=0.0, mark_px=12345.0)] + + class _FakeRead: + account_overview = _FakeAccountOverview() + user_positions = _FakeUserPositions() + user_open_orders = _FakeUserOpenOrders() + market_prices = _FakeMarketPrices() + + mid, *_ = asyncio.run(mm._sync_state(_FakeRead(), market, "0xsub")) + assert mid == 0.0 + + def test_main_returns_nonzero_for_value_error(monkeypatch: pytest.MonkeyPatch) -> None: mm = _load_market_maker_module() market = _fake_market() From c8d1444e815d3a9859e75c5330e6b64082f0e8a7 Mon Sep 17 00:00:00 2001 From: WGB5445 <919603023@qq.com> Date: Tue, 14 Apr 2026 18:12:16 +0800 Subject: [PATCH 5/9] Harden MM order-size validation and price fetch --- examples/write/market_maker_bot.py | 6 +++++- tests/test_market_maker_bot.py | 16 +++++++++++++++- 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/examples/write/market_maker_bot.py b/examples/write/market_maker_bot.py index 892bfc7..6dc3138 100644 --- a/examples/write/market_maker_bot.py +++ b/examples/write/market_maker_bot.py @@ -2,6 +2,7 @@ import argparse import asyncio +import math import os from dataclasses import dataclass from enum import StrEnum @@ -97,6 +98,9 @@ def _compute_quotes( if abs(inventory) >= settings.max_inventory: return QuoteDecision(status=QuoteStatus.PAUSE_INVENTORY_LIMIT) + if not math.isfinite(settings.order_size) or settings.order_size <= 0: + return QuoteDecision(status=QuoteStatus.PAUSE_SIZE_INVALID) + valid_size = round_to_valid_order_size( settings.order_size, lot_size=lot_size, @@ -149,7 +153,7 @@ async def _sync_state( overview_task = read.account_overview.get_by_addr(sub_addr=subaccount_addr) positions_task = read.user_positions.get_by_addr(sub_addr=subaccount_addr, limit=100) orders_task = read.user_open_orders.get_by_addr(sub_addr=subaccount_addr, limit=200) - prices_task = read.market_prices.get_all() + prices_task = read.market_prices.get_by_name(market.market_name) overview, positions, open_orders, prices = await asyncio.gather( overview_task, diff --git a/tests/test_market_maker_bot.py b/tests/test_market_maker_bot.py index b1602b7..04a7436 100644 --- a/tests/test_market_maker_bot.py +++ b/tests/test_market_maker_bot.py @@ -64,6 +64,19 @@ def test_compute_quotes_size_invalid_status() -> None: assert decision.status is mm.QuoteStatus.PAUSE_SIZE_INVALID +def test_compute_quotes_negative_size_invalid_status() -> None: + mm = _load_market_maker_module() + market = _fake_market() + settings = mm.MMSettings(order_size=-0.001) + decision = mm._compute_quotes( + mid=100000.0, + inventory=0.0, + market=market, + settings=settings, + ) + assert decision.status is mm.QuoteStatus.PAUSE_SIZE_INVALID + + def test_compute_quotes_inventory_limit_status() -> None: mm = _load_market_maker_module() market = _fake_market() @@ -133,7 +146,8 @@ async def get_by_addr(self, sub_addr, limit): return SimpleNamespace(items=[]) class _FakeMarketPrices: - async def get_all(self): + async def get_by_name(self, market_name): + assert market_name == market.market_name return [SimpleNamespace(market=market.market_addr, mid_px=0.0, mark_px=12345.0)] class _FakeRead: From 9b3207c02eb4f159aa8bb2d53f3c0543e914cc4a Mon Sep 17 00:00:00 2001 From: WGB5445 <919603023@qq.com> Date: Tue, 14 Apr 2026 18:21:54 +0800 Subject: [PATCH 6/9] Validate spread and skew-derived quote prices --- examples/write/market_maker_bot.py | 13 +++++++++++++ tests/test_market_maker_bot.py | 27 +++++++++++++++++++++++++++ 2 files changed, 40 insertions(+) diff --git a/examples/write/market_maker_bot.py b/examples/write/market_maker_bot.py index 6dc3138..1dcbd12 100644 --- a/examples/write/market_maker_bot.py +++ b/examples/write/market_maker_bot.py @@ -87,6 +87,9 @@ def _compute_quotes( if mid <= 0: return QuoteDecision(status=QuoteStatus.PAUSE_NO_PRICE) + if not math.isfinite(settings.spread) or settings.spread <= 0: + raise ValueError("spread must be a finite value > 0; adjust --spread") + tick_human = tick_size / (10**market.px_decimals) min_spread = tick_human / mid if settings.spread < min_spread: @@ -115,6 +118,11 @@ def _compute_quotes( raw_bid = mid * (1.0 - half_spread - skew) raw_ask = mid * (1.0 + half_spread - skew) + if not math.isfinite(raw_bid) or not math.isfinite(raw_ask) or raw_bid <= 0 or raw_ask <= 0: + raise ValueError( + "computed quote prices are non-positive/invalid; adjust --skew-per-unit " + "or --max-inventory", + ) bid = round_to_tick_size( raw_bid, @@ -136,6 +144,11 @@ def _compute_quotes( px_decimals=market.px_decimals, round_up=True, ) + if not math.isfinite(bid) or not math.isfinite(ask) or bid <= 0 or ask <= 0: + raise ValueError( + "rounded quote prices are non-positive/invalid; adjust --skew-per-unit " + "or --max-inventory", + ) return QuoteDecision( status=QuoteStatus.OK, diff --git a/tests/test_market_maker_bot.py b/tests/test_market_maker_bot.py index 04a7436..f5b95bf 100644 --- a/tests/test_market_maker_bot.py +++ b/tests/test_market_maker_bot.py @@ -103,6 +103,33 @@ def test_compute_quotes_spread_too_tight_raises() -> None: ) +@pytest.mark.parametrize("spread", [float("nan"), float("inf")]) +def test_compute_quotes_non_finite_spread_raises(spread: float) -> None: + mm = _load_market_maker_module() + market = _fake_market() + settings = mm.MMSettings(spread=spread) + with pytest.raises(ValueError, match="spread must be a finite value > 0"): + mm._compute_quotes( + mid=100000.0, + inventory=0.0, + market=market, + settings=settings, + ) + + +def test_compute_quotes_extreme_skew_raises() -> None: + mm = _load_market_maker_module() + market = _fake_market() + settings = mm.MMSettings(skew_per_unit=2.0, max_inventory=10.0) + with pytest.raises(ValueError, match="adjust --skew-per-unit or --max-inventory"): + mm._compute_quotes( + mid=100000.0, + inventory=1.0, + market=market, + settings=settings, + ) + + def test_parse_args_accepts_named_config_network_key(monkeypatch: pytest.MonkeyPatch) -> None: mm = _load_market_maker_module() network_key = "local" if "local" in mm.NAMED_CONFIGS else next(iter(mm.NAMED_CONFIGS)) From c697730c9bfdef56cb5b8850866d826894e740b2 Mon Sep 17 00:00:00 2001 From: WGB5445 <919603023@qq.com> Date: Tue, 14 Apr 2026 18:33:52 +0800 Subject: [PATCH 7/9] Cancel resting orders on pause guards --- examples/write/market_maker_bot.py | 16 ++++++++++ tests/test_market_maker_bot.py | 47 ++++++++++++++++++++++++++++++ 2 files changed, 63 insertions(+) diff --git a/examples/write/market_maker_bot.py b/examples/write/market_maker_bot.py index 1dcbd12..687e4ab 100644 --- a/examples/write/market_maker_bot.py +++ b/examples/write/market_maker_bot.py @@ -284,9 +284,25 @@ async def _run_cycle( print( f" paused: margin {margin_usage * 100:.2f}% > {settings.max_margin_usage * 100:.2f}%" ) + if (settings.dry_run or write is not None) and open_order_ids: + await _cancel_market_orders( + write, + market_name=market.market_name, + order_ids=open_order_ids, + subaccount_addr=subaccount_addr, + dry_run=settings.dry_run, + ) return if mid is None: print(" paused: no mid price available") + if (settings.dry_run or write is not None) and open_order_ids: + await _cancel_market_orders( + write, + market_name=market.market_name, + order_ids=open_order_ids, + subaccount_addr=subaccount_addr, + dry_run=settings.dry_run, + ) return decision = _compute_quotes( diff --git a/tests/test_market_maker_bot.py b/tests/test_market_maker_bot.py index f5b95bf..2f89b8c 100644 --- a/tests/test_market_maker_bot.py +++ b/tests/test_market_maker_bot.py @@ -234,3 +234,50 @@ async def _fake_run_cycle(*args, **kwargs): exit_code = asyncio.run(mm.main()) assert exit_code == 2 + + +@pytest.mark.parametrize( + ("mid", "margin_usage"), + [ + (100000.0, 0.9), # margin guard + (None, 0.1), # no-price guard + ], +) +def test_run_cycle_pause_guards_cancel_resting_orders( + monkeypatch: pytest.MonkeyPatch, mid: float | None, margin_usage: float +) -> None: + mm = _load_market_maker_module() + market = _fake_market() + settings = mm.MMSettings(dry_run=True, max_margin_usage=0.5) + + async def _fake_sync_state(read, market_arg, subaccount_addr): + assert market_arg is market + assert subaccount_addr == "0xsub" + return mid, 0.0, margin_usage, ["oid-1", "oid-2"] + + calls: list[list[str]] = [] + + async def _fake_cancel_market_orders( + write, market_name, order_ids, subaccount_addr, dry_run + ) -> tuple[int, int]: + assert write is None + assert market_name == "BTC/USD" + assert subaccount_addr == "0xsub" + assert dry_run is True + calls.append(order_ids) + return len(order_ids), 0 + + monkeypatch.setattr(mm, "_sync_state", _fake_sync_state) + monkeypatch.setattr(mm, "_cancel_market_orders", _fake_cancel_market_orders) + + asyncio.run( + mm._run_cycle( + 1, + read=SimpleNamespace(), + write=None, + market=market, + subaccount_addr="0xsub", + settings=settings, + ) + ) + assert calls == [["oid-1", "oid-2"]] From 554b32a03ceabf61669847eb23eef3c911e5cca3 Mon Sep 17 00:00:00 2001 From: WGB5445 <919603023@qq.com> Date: Tue, 14 Apr 2026 18:43:56 +0800 Subject: [PATCH 8/9] Refine size error message and cancel on invalid mid --- examples/write/market_maker_bot.py | 13 ++++++++- tests/test_market_maker_bot.py | 44 ++++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+), 1 deletion(-) diff --git a/examples/write/market_maker_bot.py b/examples/write/market_maker_bot.py index 687e4ab..7eb4947 100644 --- a/examples/write/market_maker_bot.py +++ b/examples/write/market_maker_bot.py @@ -326,9 +326,20 @@ async def _run_cycle( ) return if decision.status is QuoteStatus.PAUSE_SIZE_INVALID: - raise ValueError("order size rounds to zero; adjust --order-size or market lot/min size") + raise ValueError( + "invalid order size: must be finite and > 0, and must not round to 0 after " + "market lot/min-size constraints; adjust --order-size or market lot/min size" + ) if decision.status is QuoteStatus.PAUSE_NO_PRICE: print(" paused: invalid mid price") + if (settings.dry_run or write is not None) and open_order_ids: + await _cancel_market_orders( + write, + market_name=market.market_name, + order_ids=open_order_ids, + subaccount_addr=subaccount_addr, + dry_run=settings.dry_run, + ) return if decision.bid is None or decision.ask is None or decision.size is None: diff --git a/tests/test_market_maker_bot.py b/tests/test_market_maker_bot.py index 2f89b8c..78234ae 100644 --- a/tests/test_market_maker_bot.py +++ b/tests/test_market_maker_bot.py @@ -281,3 +281,47 @@ async def _fake_cancel_market_orders( ) ) assert calls == [["oid-1", "oid-2"]] + + +def test_run_cycle_invalid_mid_price_cancels_resting_orders( + monkeypatch: pytest.MonkeyPatch, +) -> None: + mm = _load_market_maker_module() + market = _fake_market() + settings = mm.MMSettings(dry_run=True, max_margin_usage=0.5) + + async def _fake_sync_state(read, market_arg, subaccount_addr): + assert market_arg is market + assert subaccount_addr == "0xsub" + return 100000.0, 0.0, 0.1, ["oid-1", "oid-2"] + + def _fake_compute_quotes(*, mid, inventory, market, settings): + return mm.QuoteDecision(status=mm.QuoteStatus.PAUSE_NO_PRICE) + + calls: list[list[str]] = [] + + async def _fake_cancel_market_orders( + write, market_name, order_ids, subaccount_addr, dry_run + ) -> tuple[int, int]: + assert write is None + assert market_name == "BTC/USD" + assert subaccount_addr == "0xsub" + assert dry_run is True + calls.append(order_ids) + return len(order_ids), 0 + + monkeypatch.setattr(mm, "_sync_state", _fake_sync_state) + monkeypatch.setattr(mm, "_compute_quotes", _fake_compute_quotes) + monkeypatch.setattr(mm, "_cancel_market_orders", _fake_cancel_market_orders) + + asyncio.run( + mm._run_cycle( + 1, + read=SimpleNamespace(), + write=None, + market=market, + subaccount_addr="0xsub", + settings=settings, + ) + ) + assert calls == [["oid-1", "oid-2"]] From 95f3fa70006897d83fd9bd62e18bbf9ce6f7b767 Mon Sep 17 00:00:00 2001 From: WGB5445 <919603023@qq.com> Date: Thu, 16 Apr 2026 00:55:40 +0800 Subject: [PATCH 9/9] docs: add market maker bot documentation to README Document the market maker bot example with features, usage instructions, and configuration options. The bot demonstrates building a trading bot with inventory skew, margin management, and dry-run mode support. Co-Authored-By: Claude Sonnet 4.6 --- README.md | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/README.md b/README.md index feaf5b7..9993a16 100644 --- a/README.md +++ b/README.md @@ -149,6 +149,39 @@ See the [examples](examples) directory for complete working examples: - **[examples/read](examples/read)** - REST API queries (markets, prices, positions, orders) - **[examples/read/ws](examples/read/ws)** - WebSocket subscriptions (real-time streaming) - **[examples/write](examples/write)** - Trading operations (orders, deposits, withdrawals) + - **[examples/write/market_maker_bot.py](examples/write/market_maker_bot.py)** - Complete market maker bot implementation with inventory skew, margin management, and dry-run mode + +### Market Maker Bot + +The SDK includes a complete market maker bot example that demonstrates how to build a trading bot using Decibel. The bot: + +- Places bid/ask quotes around the mid-price with configurable spread +- Manages inventory with skew adjustments to encourage mean-reversion +- Monitors margin usage and pauses quoting when limits are exceeded +- Supports both dry-run (simulation) and live trading modes +- Includes configurable parameters: spread, order size, inventory limits, refresh interval, and more +- Uses POST_ONLY orders for predictable fills + +To run the bot, set environment variables and execute: + +```bash +# Dry-run mode (no transactions) +export SUBACCOUNT_ADDRESS="0x..." +export NETWORK="testnet" +python examples/write/market_maker_bot.py --dry-run + +# Live mode (requires PRIVATE_KEY) +export PRIVATE_KEY="0x..." +python examples/write/market_maker_bot.py \ + --market="BTC/USD" \ + --spread=0.001 \ + --order-size=0.001 \ + --max-inventory=0.01 \ + --max-margin-usage=0.5 \ + --refresh-interval=20 +``` + +Use `python examples/write/market_maker_bot.py --help` to see all available options. ## API Reference