Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,14 @@ KURU_USE_ACCESS_LIST=true
# KURU_RECONCILIATION_INTERVAL=3.0
# KURU_RECONCILIATION_THRESHOLD=5.0

# ========================================
# INFLUXDB METRICS (optional — omit to disable)
# ========================================

# INFLUX_URL=http://localhost:8181
# INFLUX_TOKEN=your_token_here
# INFLUX_DATABASE=mm_bot

# ========================================
# OPERATIONAL STRATEGY SETTINGS
# ========================================
Expand Down
47 changes: 36 additions & 11 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,33 +52,58 @@ Key tracking dicts on `Bot` (all set/cleared together in callbacks):

**Invariant:** A cloid in `active_cloids` always has a matching entry in both `active_orders` and `order_sizes`. These three are always set and cleared together.

### PropMaintain logic (`_generate_orders_with_prop_maintain`)
### Pluggable quoter system

For each quoter level, each side independently checks whether the existing order's edge is above the cancel threshold (`baseline_edge × (1 - PROP_MAINTAIN)`). The check chain:
The quoter layer is split into:

1. Order found in REST API result → use API price for edge check
2. Order in `preregistered_orders` → just sent, awaiting confirmation → hold
3. Order in `active_orders` → confirmed via callback but REST API hasn't indexed it yet → use callback price for full edge check (logs `[callback]`)
4. Order in `active_cloids` but not `active_orders` → unknown state → hold
- `mm_bot/quoter/base.py` — `BaseQuoter` ABC. Implement `decide(ctx: QuoterContext) -> QuoterDecision`.
- `mm_bot/quoter/context.py` — `QuoterContext` (frozen snapshot of market state) and `QuoterDecision` (cancels + new orders).
- `mm_bot/quoter/skew_quoter.py` — `SkewQuoter`, the built-in strategy (position-skew + PropMaintain).
- `mm_bot/quoter/registry.py` — `register_quoter(name, cls)` / `get_quoter_class(name)`.
- `mm_bot/quoter/quoter.py` — backward-compat shim (`SkewQuoter as Quoter`).

**Coupling:** when one side of a quoter is replaced, the other is force-replaced too (lines ~1090–1100 in `bot.py`). This keeps both sides priced off the same reference.
The bot creates quoters via `_initialize_quoters()` using the registry. For each iteration it:
1. Calls `_resolve_existing_orders(quoter, on_chain_by_cloid)` to build `ExistingOrder` objects from tracking dicts
2. Constructs a `QuoterContext` snapshot
3. Calls `quoter.decide(ctx)` to get cancels + new orders
4. Processes the `QuoterDecision` (discard cancelled cloids from `active_cloids`, batch into `place_orders()`)

This replaces the old `_generate_orders_with_prop_maintain` method. All per-quoter strategy logic now lives in the quoter's `decide()` method.

### Order generation flow (`_generate_orders`)

For each quoter, `_resolve_existing_orders` resolves the existing bid/ask from tracking dicts using this priority:

1. Found in `on_chain_by_cloid` (REST API) → source `"on_chain"`, price from API
2. Found in `preregistered_orders` → source `"preregistered"`, price `None`
3. Found in `active_orders` → source `"callback"`, price from callback
4. Found in `active_cloids` only → source `"unknown"`, price `None`

The `SkewQuoter.decide()` check chain:
1. source `"preregistered"` → hold (awaiting confirmation)
2. source `"unknown"` → hold
3. source `"on_chain"` or `"callback"` → edge check against cancel threshold; keep or cancel
4. Coupling: if one side replaced, force-replace the other (uses same reference price/skew)

### Cloid format

```
{side}-{baseline_edge_bps}-{timestamp_ms}
{side}-{quoter_id}-{timestamp_ms}
# e.g. bid-1.0-1771500973306, ask-15.0-1771500975944
```

Quoter-to-order matching uses cloid prefix (`bid-{bps}-` / `ask-{bps}-`). Do not change this format without updating the matching logic.
For `SkewQuoter`, `quoter_id = str(Decimal(str(baseline_edge_bps)))`, preserving the original format.
For custom quoters, `quoter_id` is set in `BaseQuoter.__init__` and must be unique and stable across restarts.

Quoter-to-order matching uses `cloid_prefix_bid` / `cloid_prefix_ask` properties on `BaseQuoter`. Do not change the format without updating matching logic.

### Quoter skew formula

`Quoter.get_bid_ask_edges()` adjusts edges based on `position / max_position` (capped ±1):
`SkewQuoter._get_skewed_edges()` adjusts edges based on `position / max_position` (capped ±1):
- Long position → widen bids (slow to buy more), tighten asks (eager to sell)
- Short position → tighten bids (eager to buy), widen asks (slow to sell more)

Skew magnitude is controlled by `PROP_SKEW_ENTRY` and `PROP_SKEW_EXIT`.
Skew magnitude is controlled by `prop_skew_entry` and `prop_skew_exit`.

### Shutdown

Expand Down
275 changes: 210 additions & 65 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,101 +1,246 @@
# Kuru Market Making Bot

A market-making bot for Kuru DEX using the refactored `kuru-sdk-py` client.
An async market-making bot for [Kuru DEX](https://kuru.io) on Monad. Maintains bid/ask quotes using a **PropMaintain** strategy — only cancels and replaces orders whose edge has drifted below a threshold, minimizing gas costs.

## What Changed
## Quick Start

This repo now tracks SDK v0.1.9+ behavior:
```bash
git clone https://github.com/kuru-labs/mm-example.git
cd mm-example
python3 -m venv venv && source venv/bin/activate
pip install -r requirements.txt
```

- Decimal-native order/position math in bot state and fill handling
- Full `ConfigManager.load_all_configs(...)` bootstrap path
- Typed SDK error handling (`Kuru*Error`) for retries and recovery
- Cancel-all flow aligned with SDK semantics (no tx-hash return assumptions)
```bash
cp .env.example .env
cp bot_config.example.toml bot_config.toml
```

## Architecture
Set your credentials in `.env`:
```
PRIVATE_KEY=your_private_key_without_0x
```

Core modules:
Set your market and oracle in `bot_config.toml`. Available market symbols and their `market_address` values are listed in [kuru-exchange-server/config/markets.toml](https://github.com/Kuru-Labs/kuru-exchange-server/blob/master/config/markets.toml):
```toml
market_address = "0x..." # orderbookAddress from markets.toml
oracle_source = "kuru"
kuru_symbol = "mon_ausd" # symbol from markets.toml
```

- `mm_bot/main.py`: process startup, logging, signal handling
- `mm_bot/config/config.py`: loads operational config (`bot_config.toml`) and SDK config bundle
- `mm_bot/bot/bot.py`: quoting loop, order lifecycle callbacks, cancellation/reconciliation, typed recovery
- `mm_bot/quoter/quoter.py`: skewed bid/ask generation
- `mm_bot/position/position_tracker.py`: Decimal-native position persistence (`tracking/position_state.json`)
- `mm_bot/pricing/oracle.py`: oracle sources (`kuru` websocket or `coinbase` REST)
- `mm_bot/pnl/tracker.py`: Decimal-native PnL display
**Deposit tokens into your margin account** before running. The bot places orders from your Kuru margin balance — a zero balance means no orders will go through:
```bash
# Check your current margin balance
python deposit.py --token 0x... --check

## Install
# Deposit tokens (use the token address for your market's base or quote token)
python deposit.py --token 0x... --amount 100
```

Then start the bot:
```bash
git clone https://github.com/kuru-labs/mm-example.git
cd mm-example
python3 -m venv venv
source venv/bin/activate
pip install -r requirements.txt
python -m mm_bot.main
```

Logs appear under `tracking/`.

---

## Configuration

The bot uses:
The bot uses two config files:

1. `bot_config.toml` for strategy/operational settings
2. `.env` for secrets and SDK runtime settings
- **`bot_config.toml`** — strategy and operational settings (hot-reloadable)
- **`.env`** — secrets and SDK runtime settings

### Required `.env`
### Required `.env` keys

- `PRIVATE_KEY`
- `MARKET_ADDRESS` (required unless `strategy.market_address` is set in `bot_config.toml`)
| Key | Notes |
|-----|-------|
| `PRIVATE_KEY` | No `0x` prefix |
| `MARKET_ADDRESS` | Can also be set in `bot_config.toml` as `strategy.market_address` |

### Common SDK `.env` settings

- `RPC_URL` (default `https://rpc.monad.xyz`)
- `RPC_WS_URL` (default `wss://rpc.monad.xyz`)
- `KURU_WS_URL` (default `wss://ws.kuru.io/`)
- `KURU_API_URL` (default `https://api.kuru.io/`)
- `KURU_RPC_LOGS_SUBSCRIPTION` (default `monadLogs`)
- `KURU_GAS_BUFFER_MULTIPLIER` (default from SDK)
- `KURU_USE_ACCESS_LIST` (`true`/`false`)
- `KURU_POST_ONLY` (`true`/`false`)
- `KURU_RPC_WS_MAX_RECONNECT_ATTEMPTS`
- `KURU_RPC_WS_RECONNECT_DELAY`
- `KURU_RPC_WS_MAX_RECONNECT_DELAY`
- `KURU_RECONCILIATION_INTERVAL`
- `KURU_RECONCILIATION_THRESHOLD`
| Key | Default |
|-----|---------|
| `RPC_URL` | `https://rpc.monad.xyz` |
| `RPC_WS_URL` | `wss://rpc.monad.xyz` |
| `KURU_WS_URL` | `wss://ws.kuru.io/` |
| `KURU_API_URL` | `https://api.kuru.io/` |
| `KURU_RPC_LOGS_SUBSCRIPTION` | `monadLogs` |
| `KURU_GAS_BUFFER_MULTIPLIER` | SDK default |
| `KURU_USE_ACCESS_LIST` | `true`/`false` |
| `KURU_POST_ONLY` | `true`/`false` |

### Oracle: Kuru exchange server

When `oracle_source = "kuru"`, the bot connects to `wss://exchange.kuru.io` and subscribes to live orderbook updates for the given symbol, computing a mid-price from the best bid/ask.

```toml
oracle_source = "kuru"
kuru_symbol = "mon_ausd"

# Which Monad block state to read prices from (default: "committed")
# proposed → freshest prices, can revert on reorg
# voted → validators voted
# finalized → finalized by validators
# committed → highest finality, slightly lagging
kuru_depth_state = "proposed"
```

Available market symbols are defined in the Kuru exchange server config:
[kuru-exchange-server/config/markets.toml](https://github.com/Kuru-Labs/kuru-exchange-server/blob/master/config/markets.toml)

See `.env.example` and `bot_config.example.toml`.
---

## Run
### Key `bot_config.toml` parameters

Hot-reloadable (no restart needed):

| Parameter | Description |
|-----------|-------------|
| `prop_maintain` | Cancel threshold — `0.2` means cancel if edge drops below 80% of target |
| `reconcile_interval` | Seconds between position reconciliation (`0` = disabled) |

Full reinit on change (brief trading pause):

| Parameter | Description |
|-----------|-------------|
| `quoters_bps` | Spread levels in bps — `[1, 10, 15]` creates 3 bid/ask pairs |
| `quoter_type` | Quoter strategy — built-in: `"skew"` |
| `quantity` | Order size per level per side (base token units) |
| `max_position` | Inventory cap — bot skews quotes to stay within |
| `prop_skew_entry` | How aggressively to slow down position accumulation |
| `prop_skew_exit` | How aggressively to accelerate position unwind |

See `bot_config.example.toml` for a fully annotated reference, and **[TUNING.md](TUNING.md)** for a detailed guide on how to tune each parameter.

---

## Architecture

```bash
./run.sh
```
mm_bot/
├── main.py # Entry point, logging, signal handling
├── bot/bot.py # Quoting loop, order lifecycle, callbacks
├── quoter/ # Pluggable quoter system (see below)
├── config/
│ ├── config.py # BotConfig dataclass, TOML + .env loading
│ └── config_watcher.py # Hot-reload (watches bot_config.toml every 5s)
├── position/ # Position tracking, persistence
├── pricing/oracle.py # Oracle sources (Kuru exchange server WS or Coinbase REST)
└── pnl/tracker.py # PnL display
```

or:
The bot tracks all order state from **WebSocket callbacks**, not the REST API. The REST API lags ~2 seconds behind on-chain events and is only used for startup cleanup, orphan detection, and shutdown.

### Quoter system

Each quoter manages one bid/ask pair at one spread level. On every iteration the bot:

1. Resolves existing order state from its tracking dicts into a frozen `QuoterContext` snapshot
2. Calls `quoter.decide(ctx)` — the quoter returns cancels + new orders
3. Batches everything into a single `place_orders()` transaction

| Module | Role |
|--------|------|
| `quoter/base.py` | `BaseQuoter` ABC — implement `decide(ctx) -> QuoterDecision` |
| `quoter/context.py` | `QuoterContext` (frozen snapshot) and `QuoterDecision` |
| `quoter/skew_quoter.py` | Built-in `SkewQuoter` — position skew + PropMaintain cancel logic |
| `quoter/registry.py` | `register_quoter()` / `get_quoter_class()` |

### Writing a custom quoter

```python
from decimal import Decimal
from mm_bot.quoter.base import BaseQuoter
from mm_bot.quoter.context import QuoterContext, QuoterDecision
from mm_bot.quoter.registry import register_quoter
from mm_bot.kuru_imports import Order, OrderType, OrderSide

class MyQuoter(BaseQuoter):
def __init__(self, edge_bps: float, quantity: Decimal):
super().__init__(quoter_id=f"my-{edge_bps}", quantity=quantity)
self.edge = Decimal(str(edge_bps))

def decide(self, ctx: QuoterContext) -> QuoterDecision:
cancels = []
if ctx.existing_bid and ctx.existing_bid.source not in ("preregistered", "unknown"):
cancels.append(ctx.existing_bid.cloid)
if ctx.existing_ask and ctx.existing_ask.source not in ("preregistered", "unknown"):
cancels.append(ctx.existing_ask.cloid)

new_orders = []
if not ctx.stop_bids:
new_orders.append(Order(
cloid=self.make_cloid("bid"), order_type=OrderType.LIMIT,
side=OrderSide.BUY, size=self.quantity, post_only=False,
price=self.price_from_edge(self.edge, OrderSide.BUY, ctx.reference_price),
))
if not ctx.stop_asks:
new_orders.append(Order(
cloid=self.make_cloid("ask"), order_type=OrderType.LIMIT,
side=OrderSide.SELL, size=self.quantity, post_only=False,
price=self.price_from_edge(self.edge, OrderSide.SELL, ctx.reference_price),
))
return QuoterDecision(cancels=cancels, new_orders=new_orders)

@classmethod
def from_config(cls, config_section: dict) -> "MyQuoter":
return cls(
edge_bps=float(config_section["baseline_edge_bps"]),
quantity=Decimal(str(config_section["quantity"])),
)

register_quoter("my_quoter", MyQuoter)
```

```bash
source venv/bin/activate
PYTHONPATH=. python3 mm_bot/main.py
Then in `bot_config.toml`:
```toml
[[strategy.quoters]]
type = "my_quoter"
baseline_edge_bps = 10.0
quantity = 500
```

## Runtime Notes
`QuoterContext` fields:

| Field | Type | Description |
|-------|------|-------------|
| `reference_price` | `Decimal` | Current fair price from oracle |
| `current_position` | `Decimal` | Net position (positive = long) |
| `max_position` | `Decimal` | Position limit from config |
| `existing_bid` | `ExistingOrder?` | Bot's current bid for this quoter |
| `existing_ask` | `ExistingOrder?` | Bot's current ask for this quoter |
| `stop_bids` | `bool` | `True` if position ≥ max_position |
| `stop_asks` | `bool` | `True` if position ≤ -max_position |
| `prop_maintain` | `float` | Cancel threshold factor from config |
| `price_precision` | `Decimal` | Market price precision |

`ExistingOrder.source`: `"on_chain"` · `"callback"` · `"preregistered"` · `"unknown"`

- Position state is persisted as Decimal-safe values in `tracking/position_state.json`.
- Terminal order states now include `ORDER_TIMEOUT` and `ORDER_FAILED` handling.
- SDK typed errors are classified for retry vs skip behavior:
- execution errors: `KuruInsufficientFundsError`, `KuruContractError`, `KuruOrderError`
- connectivity errors: `KuruConnectionError`, `KuruWebSocketError`, `KuruTimeoutError`
- API/auth errors: `KuruAuthorizationError`
---

## Troubleshooting

- Orders not placing:
- Check margin balances and wallet gas balance
- Confirm market address and token decimals from on-chain market config
- Frequent retries:
- Check RPC/WebSocket health
- Tune `KURU_RPC_WS_*` and `KURU_RECONCILIATION_*`
- No reference price:
- Verify selected oracle source in `bot_config.toml`
- Check connectivity to Kuru WS or Coinbase API
**Orders not placing**
- Check margin balances and wallet gas balance
- Confirm market address and token decimals from on-chain market config

**Frequent retries / connectivity issues**
- Check RPC/WebSocket health
- Tune `KURU_RPC_WS_*` reconnect settings in `.env`

**No reference price**
- Verify `oracle_source` in `bot_config.toml`
- Check connectivity to Kuru WS or Coinbase API

**Position drift after restart**
- Position is persisted in `tracking/position_state.json`
- Use `override_start_position` in `bot_config.toml` to force a specific starting value

---

## License

Expand Down
Loading