diff --git a/README.md b/README.md index 5d4b0d5..36b70b4 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 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 \ + --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 diff --git a/examples/write/market_maker_bot.py b/examples/write/market_maker_bot.py new file mode 100644 index 0000000..759f2de --- /dev/null +++ b/examples/write/market_maker_bot.py @@ -0,0 +1,671 @@ +""" +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 +import asyncio +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 +from aptos_sdk.ed25519 import PrivateKey + +from decibel import ( + NAMED_CONFIGS, + BaseSDKOptions, + DecibelWriteDex, + GasPriceManager, + PlaceOrderSuccess, + TimeInForce, +) +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 + + +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: Decimal | None = None + ask: Decimal | None = None + size: Decimal | None = None + + +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 _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, + inventory: float, + market: PerpMarket, + settings: MMSettings, +) -> QuoteDecision: + tick_size = int(market.tick_size) + lot_size = int(market.lot_size) + min_size = int(market.min_size) + + 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") + 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_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_decimal( + _to_decimal(settings.order_size), + lot_size=lot_size, + sz_decimals=market.sz_decimals, + min_size=min_size, + ) + if valid_size <= 0: + return QuoteDecision(status=QuoteStatus.PAUSE_SIZE_INVALID) + + half_spread = spread_d / Decimal(2) + skew = inventory_d * _to_decimal(settings.skew_per_unit) + + 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_decimal( + raw_bid, + tick_size=tick_size, + px_decimals=market.px_decimals, + round_up=False, + ) + ask = _round_to_tick_size_decimal( + raw_ask, + tick_size=tick_size, + px_decimals=market.px_decimals, + round_up=True, + ) + + if ask <= bid: + ask = _round_to_tick_size_decimal( + bid + tick_human, + tick_size=tick_size, + px_decimals=market.px_decimals, + round_up=True, + ) + 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", + ) + + return QuoteDecision( + status=QuoteStatus.OK, + bid=bid, + ask=ask, + size=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_by_name(market.market_name) + + 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 + 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 | None, + 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 + 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, + 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: Decimal, + size: Decimal, + dry_run: bool, +) -> None: + side = "bid" if is_buy else "ask" + if dry_run: + print(f" [dry-run] would place {side}: {price} x {size}") + 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=_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, + subaccount_addr=subaccount_addr, + ) + 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}%" + ) + 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( + mid=mid, + inventory=inventory, + market=market, + settings=settings, + ) + 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 (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.status is QuoteStatus.PAUSE_SIZE_INVALID: + 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: + raise RuntimeError(f"unexpected quote decision: {decision.status}") + + bid, ask, size = decision.bid, decision.ask, decision.size + print(f" quotes: bid={bid} ask={ask} size={size}") + + failed = 0 + 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, + 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. " + "Reference only; avoid mainnet live trading unless fully audited." + ), + ) + + 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"), + choices=tuple(NAMED_CONFIGS), + help="Network profile key 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=_env_default_numeric("MM_SPREAD", 0.001, float), + ) + parser.add_argument( + "--order-size", + type=float, + default=_env_default_numeric("MM_ORDER_SIZE", 0.001, float), + ) + parser.add_argument( + "--max-inventory", + type=float, + default=_env_default_numeric("MM_MAX_INVENTORY", 0.005, float), + ) + parser.add_argument( + "--skew-per-unit", + type=float, + default=_env_default_numeric("MM_SKEW_PER_UNIT", 0.0001, float), + ) + parser.add_argument( + "--max-margin-usage", + type=float, + 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=_env_default_numeric("MM_REFRESH_S", 20.0, float), + help="Seconds between cycles", + ) + parser.add_argument( + "--cooldown", + type=float, + 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=_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=_env_default_numeric("MAX_CYCLES", 0, int), + 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() + + +def _validate_settings(settings: MMSettings) -> None: + 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: + 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, + ) + _validate_settings(settings) + + 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}" + ) + # 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) + 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 ValueError as exc: + print(f"fatal config error: {exc}") + return 2 + 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())) diff --git a/tests/test_market_maker_bot.py b/tests/test_market_maker_bot.py new file mode 100644 index 0000000..3933674 --- /dev/null +++ b/tests/test_market_maker_bot.py @@ -0,0 +1,404 @@ +from __future__ import annotations + +import argparse +import asyncio +import importlib.util +import sys +from decimal import Decimal +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_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() + 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, + ) + + +@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)) + monkeypatch.setattr(sys, "argv", ["market_maker_bot.py", "--network", network_key]) + args = mm._parse_args() + 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 value for MM_SPREAD" 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() + asyncio.run( + mm._place_quote( + write=None, + market=market, + subaccount_addr="0xsub", + is_buy=True, + price=Decimal("100.5"), + size=Decimal("0.002"), + dry_run=True, + ) + ) + out = capsys.readouterr().out + 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_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( + 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 + assert "tick_size" not in write.kwargs + + +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_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: + 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() + + 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.01, + cancel_resync=0.01, + 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 + + +@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"]] + + +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"]]