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 01/14] 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 02/14] 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 03/14] 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 04/14] 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 05/14] 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 06/14] 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 07/14] 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 08/14] 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 09/14] 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 From b29ef9fa3d2b2cc8058f38e8eae81c3f655df171 Mon Sep 17 00:00:00 2001 From: WGB5445 <919603023@qq.com> Date: Fri, 17 Apr 2026 23:30:08 +0800 Subject: [PATCH 10/14] Harden MM bot parsing and switch quote math to Decimal --- examples/write/market_maker_bot.py | 132 +++++++++++++++++++++-------- tests/test_market_maker_bot.py | 69 ++++++++++++++- 2 files changed, 164 insertions(+), 37 deletions(-) diff --git a/examples/write/market_maker_bot.py b/examples/write/market_maker_bot.py index 7eb4947..56a262f 100644 --- a/examples/write/market_maker_bot.py +++ b/examples/write/market_maker_bot.py @@ -5,6 +5,7 @@ import math import os from dataclasses import dataclass +from decimal import ROUND_CEILING, ROUND_FLOOR, ROUND_HALF_EVEN, Decimal from enum import StrEnum from aptos_sdk.account import Account @@ -17,9 +18,6 @@ GasPriceManager, PlaceOrderSuccess, TimeInForce, - amount_to_chain_units, - round_to_tick_size, - round_to_valid_order_size, ) from decibel.read import DecibelReadDex, PerpMarket @@ -49,9 +47,9 @@ class QuoteStatus(StrEnum): @dataclass(frozen=True) class QuoteDecision: status: QuoteStatus - bid: float | None = None - ask: float | None = None - size: float | None = None + bid: Decimal | None = None + ask: Decimal | None = None + size: Decimal | None = None def _env_bool(name: str, default: bool = False) -> bool: @@ -73,6 +71,53 @@ def _resolve_market(markets: list[PerpMarket], requested_name: str) -> PerpMarke return None +def _to_decimal(value: float | int | str | Decimal) -> Decimal: + if isinstance(value, Decimal): + return value + return Decimal(str(value)) + + +def _decimal_scale(decimals: int) -> Decimal: + return Decimal(10) ** decimals + + +def _round_to_tick_size_decimal( + price: Decimal, tick_size: int, px_decimals: int, round_up: bool +) -> Decimal: + if price == 0: + return Decimal(0) + scale = _decimal_scale(px_decimals) + denormalized = price * scale + ticks = denormalized / Decimal(tick_size) + rounding = ROUND_CEILING if round_up else ROUND_FLOOR + rounded_ticks = ticks.to_integral_value(rounding=rounding) + rounded_units = rounded_ticks * Decimal(tick_size) + return rounded_units / scale + + +def _round_to_valid_order_size_decimal( + order_size: Decimal, + lot_size: int, + sz_decimals: int, + min_size: int, +) -> Decimal: + if order_size == 0: + return Decimal(0) + scale = _decimal_scale(sz_decimals) + normalized_min_size = Decimal(min_size) / scale + if order_size < normalized_min_size: + return normalized_min_size + denormalized = order_size * scale + lots = (denormalized / Decimal(lot_size)).to_integral_value(rounding=ROUND_HALF_EVEN) + rounded_units = lots * Decimal(lot_size) + return rounded_units / scale + + +def _decimal_to_chain_units(amount: Decimal, decimals: int) -> int: + scale = _decimal_scale(decimals) + return int((amount * scale).to_integral_value(rounding=ROUND_HALF_EVEN)) + + def _compute_quotes( *, mid: float, @@ -89,23 +134,32 @@ def _compute_quotes( 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: + if not math.isfinite(settings.max_inventory) or settings.max_inventory <= 0: + raise ValueError("max_inventory must be a finite value > 0; adjust --max-inventory") + if not math.isfinite(settings.max_margin_usage) or settings.max_margin_usage <= 0: + raise ValueError("max_margin_usage must be a finite value > 0; adjust --max-margin-usage") + + mid_d = _to_decimal(mid) + inventory_d = _to_decimal(inventory) + spread_d = _to_decimal(settings.spread) + max_inventory_d = _to_decimal(settings.max_inventory) + + tick_human = Decimal(tick_size) / _decimal_scale(market.px_decimals) + min_spread = tick_human / mid_d + if spread_d < min_spread: raise ValueError( f"spread {settings.spread} is tighter than one tick ({min_spread:.8f}); " "increase --spread", ) - if abs(inventory) >= settings.max_inventory: + if abs(inventory_d) >= max_inventory_d: 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, + valid_size = _round_to_valid_order_size_decimal( + _to_decimal(settings.order_size), lot_size=lot_size, sz_decimals=market.sz_decimals, min_size=min_size, @@ -113,24 +167,24 @@ def _compute_quotes( if valid_size <= 0: return QuoteDecision(status=QuoteStatus.PAUSE_SIZE_INVALID) - half_spread = settings.spread / 2.0 - skew = inventory * settings.skew_per_unit + half_spread = spread_d / Decimal(2) + skew = inventory_d * _to_decimal(settings.skew_per_unit) - 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: + raw_bid = mid_d * (Decimal(1) - half_spread - skew) + raw_ask = mid_d * (Decimal(1) + half_spread - skew) + if (not raw_bid.is_finite()) or (not raw_ask.is_finite()) 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( + bid = _round_to_tick_size_decimal( raw_bid, tick_size=tick_size, px_decimals=market.px_decimals, round_up=False, ) - ask = round_to_tick_size( + ask = _round_to_tick_size_decimal( raw_ask, tick_size=tick_size, px_decimals=market.px_decimals, @@ -138,13 +192,13 @@ def _compute_quotes( ) if ask <= bid: - ask = round_to_tick_size( + ask = _round_to_tick_size_decimal( bid + tick_human, tick_size=tick_size, px_decimals=market.px_decimals, round_up=True, ) - if not math.isfinite(bid) or not math.isfinite(ask) or bid <= 0 or ask <= 0: + if (not bid.is_finite()) or (not ask.is_finite()) or bid <= 0 or ask <= 0: raise ValueError( "rounded quote prices are non-positive/invalid; adjust --skew-per-unit " "or --max-inventory", @@ -237,8 +291,8 @@ async def _place_quote( market: PerpMarket, subaccount_addr: str, is_buy: bool, - price: float, - size: float, + price: Decimal, + size: Decimal, dry_run: bool, ) -> None: side = "bid" if is_buy else "ask" @@ -250,8 +304,8 @@ async def _place_quote( 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), + price=_decimal_to_chain_units(price, market.px_decimals), + size=_decimal_to_chain_units(size, market.sz_decimals), is_buy=is_buy, time_in_force=TimeInForce.PostOnly, is_reduce_only=False, @@ -406,50 +460,50 @@ def _parse_args() -> argparse.Namespace: 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("--spread", type=float, default=os.getenv("MM_SPREAD", "0.001")) parser.add_argument( "--order-size", type=float, - default=float(os.getenv("MM_ORDER_SIZE", "0.001")), + default=os.getenv("MM_ORDER_SIZE", "0.001"), ) parser.add_argument( "--max-inventory", type=float, - default=float(os.getenv("MM_MAX_INVENTORY", "0.005")), + default=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")), + default=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")), + default=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")), + default=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")), + default=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")), + default=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")), + default=os.getenv("MAX_CYCLES", "0"), help="Stop after N cycles (0 = run forever)", ) parser.add_argument( @@ -461,6 +515,13 @@ def _parse_args() -> argparse.Namespace: return parser.parse_args() +def _validate_settings(settings: MMSettings) -> None: + if not math.isfinite(settings.max_inventory) or settings.max_inventory <= 0: + raise ValueError("max_inventory must be a finite value > 0; adjust --max-inventory") + if not math.isfinite(settings.max_margin_usage) or settings.max_margin_usage <= 0: + raise ValueError("max_margin_usage must be a finite value > 0; adjust --max-margin-usage") + + async def main() -> int: args = _parse_args() @@ -490,6 +551,7 @@ async def main() -> int: max_cycles=args.max_cycles, dry_run=dry_run, ) + _validate_settings(settings) config = NAMED_CONFIGS[args.network] read = DecibelReadDex(config, api_key=node_api_key) diff --git a/tests/test_market_maker_bot.py b/tests/test_market_maker_bot.py index 78234ae..69cba93 100644 --- a/tests/test_market_maker_bot.py +++ b/tests/test_market_maker_bot.py @@ -4,6 +4,7 @@ import asyncio import importlib.util import sys +from decimal import Decimal from pathlib import Path from types import SimpleNamespace @@ -138,6 +139,19 @@ def test_parse_args_accepts_named_config_network_key(monkeypatch: pytest.MonkeyP assert args.network == network_key +def test_parse_args_invalid_env_uses_argparse_error( + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + mm = _load_market_maker_module() + monkeypatch.setenv("MM_SPREAD", "abc") + monkeypatch.setattr(sys, "argv", ["market_maker_bot.py"]) + with pytest.raises(SystemExit) as excinfo: + mm._parse_args() + assert excinfo.value.code == 2 + err = capsys.readouterr().err + assert "invalid float value" in err + + def test_place_quote_dry_run_uses_price_x_size(capsys: pytest.CaptureFixture[str]) -> None: mm = _load_market_maker_module() market = _fake_market() @@ -147,8 +161,8 @@ def test_place_quote_dry_run_uses_price_x_size(capsys: pytest.CaptureFixture[str market=market, subaccount_addr="0xsub", is_buy=True, - price=100.5, - size=0.002, + price=Decimal("100.5"), + size=Decimal("0.002"), dry_run=True, ) ) @@ -156,6 +170,57 @@ 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_validate_settings_rejects_non_finite_limits() -> None: + mm = _load_market_maker_module() + with pytest.raises(ValueError, match="max_inventory must be a finite value > 0"): + mm._validate_settings(mm.MMSettings(max_inventory=float("nan"))) + with pytest.raises(ValueError, match="max_margin_usage must be a finite value > 0"): + mm._validate_settings(mm.MMSettings(max_margin_usage=float("nan"))) + + +def test_decimal_rounding_helpers_stable_for_tiny_values() -> None: + mm = _load_market_maker_module() + down = mm._round_to_tick_size_decimal( + Decimal("0.00000000000123"), tick_size=1, px_decimals=12, round_up=False + ) + up = mm._round_to_tick_size_decimal( + Decimal("0.00000000000123"), tick_size=1, px_decimals=12, round_up=True + ) + assert down == Decimal("0.000000000001") + assert up == Decimal("0.000000000002") + assert mm._decimal_to_chain_units(Decimal("0.000000000001"), 12) == 1 + + +def test_place_quote_live_uses_integer_chain_units() -> None: + mm = _load_market_maker_module() + market = _fake_market() + + class _FakeWrite: + def __init__(self): + self.kwargs = None + + async def place_order(self, **kwargs): + self.kwargs = kwargs + return SimpleNamespace(error="simulated") + + write = _FakeWrite() + asyncio.run( + mm._place_quote( + write=write, + market=market, + subaccount_addr="0xsub", + is_buy=True, + price=Decimal("100.12"), + size=Decimal("0.1234"), + dry_run=False, + ) + ) + assert isinstance(write.kwargs["price"], int) + assert isinstance(write.kwargs["size"], int) + assert write.kwargs["price"] == 10012 + assert write.kwargs["size"] == 1234 + + def test_sync_state_uses_mid_px_without_falsy_fallback() -> None: mm = _load_market_maker_module() market = _fake_market() From b9a07c835ec4ea148432fd5d9f6342014c549c21 Mon Sep 17 00:00:00 2001 From: WGB5445 <919603023@qq.com> Date: Fri, 17 Apr 2026 23:42:04 +0800 Subject: [PATCH 11/14] Fix env default parsing and align PRIVATE_KEY docs --- README.md | 4 ++-- examples/write/market_maker_bot.py | 32 +++++++++++++++++++++--------- tests/test_market_maker_bot.py | 2 +- 3 files changed, 26 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 16613bb..36b70b4 100644 --- a/README.md +++ b/README.md @@ -170,8 +170,8 @@ 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..." +# Live mode (requires PRIVATE_KEY as plain hex, no 0x prefix) +export PRIVATE_KEY="your_private_key_hex" python examples/write/market_maker_bot.py \ --market="BTC/USD" \ --spread=0.001 \ diff --git a/examples/write/market_maker_bot.py b/examples/write/market_maker_bot.py index 56a262f..2942c80 100644 --- a/examples/write/market_maker_bot.py +++ b/examples/write/market_maker_bot.py @@ -449,6 +449,16 @@ def _parse_args() -> argparse.Namespace: "orders and places a POST_ONLY bid/ask around mid price with inventory skew." ), ) + + def _env_default_numeric(name: str, default: float | int, caster: type[float] | type[int]): + raw = os.getenv(name) + if raw is None: + return default + try: + return caster(raw) + except ValueError: + parser.error(f"invalid value for {name}: {raw!r} (expected {caster.__name__})") + parser.add_argument( "--network", default=os.getenv("NETWORK", "testnet"), @@ -460,50 +470,54 @@ def _parse_args() -> argparse.Namespace: default=os.getenv("MARKET_NAME", "BTC/USD"), help="Market symbol, e.g. BTC/USD", ) - parser.add_argument("--spread", type=float, default=os.getenv("MM_SPREAD", "0.001")) + parser.add_argument( + "--spread", + type=float, + default=_env_default_numeric("MM_SPREAD", 0.001, float), + ) parser.add_argument( "--order-size", type=float, - default=os.getenv("MM_ORDER_SIZE", "0.001"), + default=_env_default_numeric("MM_ORDER_SIZE", 0.001, float), ) parser.add_argument( "--max-inventory", type=float, - default=os.getenv("MM_MAX_INVENTORY", "0.005"), + default=_env_default_numeric("MM_MAX_INVENTORY", 0.005, float), ) parser.add_argument( "--skew-per-unit", type=float, - default=os.getenv("MM_SKEW_PER_UNIT", "0.0001"), + default=_env_default_numeric("MM_SKEW_PER_UNIT", 0.0001, float), ) parser.add_argument( "--max-margin-usage", type=float, - default=os.getenv("MM_MAX_MARGIN", "0.5"), + default=_env_default_numeric("MM_MAX_MARGIN", 0.5, float), help="Pause quoting when cross_margin_ratio exceeds this value", ) parser.add_argument( "--refresh-interval", type=float, - default=os.getenv("MM_REFRESH_S", "20"), + default=_env_default_numeric("MM_REFRESH_S", 20.0, float), help="Seconds between cycles", ) parser.add_argument( "--cooldown", type=float, - default=os.getenv("MM_COOLDOWN_S", "1.5"), + default=_env_default_numeric("MM_COOLDOWN_S", 1.5, float), help="Seconds between placing bid and ask", ) parser.add_argument( "--cancel-resync", type=float, - default=os.getenv("MM_CANCEL_RESYNC_S", "8"), + default=_env_default_numeric("MM_CANCEL_RESYNC_S", 8.0, float), help="Sleep before re-checking open orders after cancel failures", ) parser.add_argument( "--max-cycles", type=int, - default=os.getenv("MAX_CYCLES", "0"), + default=_env_default_numeric("MAX_CYCLES", 0, int), help="Stop after N cycles (0 = run forever)", ) parser.add_argument( diff --git a/tests/test_market_maker_bot.py b/tests/test_market_maker_bot.py index 69cba93..ac659d5 100644 --- a/tests/test_market_maker_bot.py +++ b/tests/test_market_maker_bot.py @@ -149,7 +149,7 @@ def test_parse_args_invalid_env_uses_argparse_error( mm._parse_args() assert excinfo.value.code == 2 err = capsys.readouterr().err - assert "invalid float value" in err + assert "invalid value for MM_SPREAD" in err def test_place_quote_dry_run_uses_price_x_size(capsys: pytest.CaptureFixture[str]) -> None: From 6d5fc0512e5fd8167fe6c3b8f7ec8bcab2878f2d Mon Sep 17 00:00:00 2001 From: WGB5445 <919603023@qq.com> Date: Sun, 19 Apr 2026 00:38:14 +0800 Subject: [PATCH 12/14] Validate all MM numeric settings up front --- examples/write/market_maker_bot.py | 22 ++++++++++++++++++---- tests/test_market_maker_bot.py | 15 +++++++++++++-- 2 files changed, 31 insertions(+), 6 deletions(-) diff --git a/examples/write/market_maker_bot.py b/examples/write/market_maker_bot.py index 2942c80..95684b5 100644 --- a/examples/write/market_maker_bot.py +++ b/examples/write/market_maker_bot.py @@ -530,10 +530,24 @@ def _env_default_numeric(name: str, default: float | int, caster: type[float] | def _validate_settings(settings: MMSettings) -> None: - if not math.isfinite(settings.max_inventory) or settings.max_inventory <= 0: - raise ValueError("max_inventory must be a finite value > 0; adjust --max-inventory") - if not math.isfinite(settings.max_margin_usage) or settings.max_margin_usage <= 0: - raise ValueError("max_margin_usage must be a finite value > 0; adjust --max-margin-usage") + errors: list[str] = [] + finite_positive_fields = ( + ("spread", settings.spread, "--spread"), + ("order_size", settings.order_size, "--order-size"), + ("max_inventory", settings.max_inventory, "--max-inventory"), + ("skew_per_unit", settings.skew_per_unit, "--skew-per-unit"), + ("max_margin_usage", settings.max_margin_usage, "--max-margin-usage"), + ("refresh_interval_s", settings.refresh_interval_s, "--refresh-interval"), + ("cooldown_s", settings.cooldown_s, "--cooldown"), + ("cancel_resync_s", settings.cancel_resync_s, "--cancel-resync"), + ) + for field_name, value, flag in finite_positive_fields: + if not math.isfinite(value) or value <= 0: + errors.append(f"{field_name} must be a finite value > 0; adjust {flag}") + if settings.max_cycles < 0: + errors.append("max_cycles must be >= 0; adjust --max-cycles") + if errors: + raise ValueError("; ".join(errors)) async def main() -> int: diff --git a/tests/test_market_maker_bot.py b/tests/test_market_maker_bot.py index ac659d5..bd0e99f 100644 --- a/tests/test_market_maker_bot.py +++ b/tests/test_market_maker_bot.py @@ -178,6 +178,17 @@ def test_validate_settings_rejects_non_finite_limits() -> None: mm._validate_settings(mm.MMSettings(max_margin_usage=float("nan"))) +def test_validate_settings_reports_multiple_invalid_fields() -> None: + mm = _load_market_maker_module() + bad = mm.MMSettings(spread=float("nan"), max_margin_usage=0.0, max_cycles=-1) + with pytest.raises(ValueError) as excinfo: + mm._validate_settings(bad) + msg = str(excinfo.value) + assert "spread must be a finite value > 0; adjust --spread" in msg + assert "max_margin_usage must be a finite value > 0; adjust --max-margin-usage" in msg + assert "max_cycles must be >= 0; adjust --max-cycles" in msg + + def test_decimal_rounding_helpers_stable_for_tiny_values() -> None: mm = _load_market_maker_module() down = mm._round_to_tick_size_decimal( @@ -284,8 +295,8 @@ async def _fake_run_cycle(*args, **kwargs): skew_per_unit=0.0001, max_margin_usage=0.5, refresh_interval=0.01, - cooldown=0.0, - cancel_resync=0.0, + cooldown=0.01, + cancel_resync=0.01, max_cycles=1, dry_run=True, ), From c1dbf3ec86e9dd2354cb36804b09e1b8b20ff57c Mon Sep 17 00:00:00 2001 From: WGB5445 <919603023@qq.com> Date: Mon, 20 Apr 2026 12:05:46 +0800 Subject: [PATCH 13/14] Avoid SDK re-rounding for already-quantized MM quotes --- examples/write/market_maker_bot.py | 1 - tests/test_market_maker_bot.py | 1 + 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/write/market_maker_bot.py b/examples/write/market_maker_bot.py index 95684b5..3074cda 100644 --- a/examples/write/market_maker_bot.py +++ b/examples/write/market_maker_bot.py @@ -310,7 +310,6 @@ async def _place_quote( 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]}...)") diff --git a/tests/test_market_maker_bot.py b/tests/test_market_maker_bot.py index bd0e99f..3933674 100644 --- a/tests/test_market_maker_bot.py +++ b/tests/test_market_maker_bot.py @@ -230,6 +230,7 @@ async def place_order(self, **kwargs): assert isinstance(write.kwargs["size"], int) assert write.kwargs["price"] == 10012 assert write.kwargs["size"] == 1234 + assert "tick_size" not in write.kwargs def test_sync_state_uses_mid_px_without_falsy_fallback() -> None: From b48d762f1bf0f97117916c581181d649f45530b7 Mon Sep 17 00:00:00 2001 From: WGB5445 <919603023@qq.com> Date: Mon, 20 Apr 2026 12:06:59 +0800 Subject: [PATCH 14/14] Add explicit safety warnings to MM example --- examples/write/market_maker_bot.py | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/examples/write/market_maker_bot.py b/examples/write/market_maker_bot.py index 3074cda..759f2de 100644 --- a/examples/write/market_maker_bot.py +++ b/examples/write/market_maker_bot.py @@ -1,3 +1,12 @@ +""" +Reference market maker example. + +WARNING: +- This script is for educational/reference purposes only. +- Do NOT run on mainnet with real funds unless you fully understand and audit it. +- Automated trading can lose money due to market volatility, latency, and config mistakes. +""" + from __future__ import annotations import argparse @@ -445,7 +454,8 @@ 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." + "orders and places a POST_ONLY bid/ask around mid price with inventory skew. " + "Reference only; avoid mainnet live trading unless fully audited." ), ) @@ -603,8 +613,15 @@ async def main() -> int: f"refresh={settings.refresh_interval_s}s " f"cooldown={settings.cooldown_s}s dry_run={settings.dry_run}" ) + # Safety reminder for anyone running this example as-is. + if args.network == "mainnet": + print( + " WARNING: This example is reference-only and may lose funds. " + "Do not run live on mainnet without full strategy/risk validation." + ) if not settings.dry_run: + # Live-mode sends real transactions. Use with caution. private_key = PrivateKey.from_hex(private_key_hex) account = Account.load_key(private_key.hex()) gas = GasPriceManager(config)