← All notes

Building AutoP2P: a repricing bot for Binance P2P

An engineering note on AutoP2P’s early design: competition engines, layered rate limiting, trading strategies and real-time observability.

AutoP2P started with a practical frustration. I was running ads on Binance P2P, where a competitor’s price adjustment can change an ad’s position. The manual alternative was to keep checking the feed and adjusting my ads.

I decided to automate it. What began as a script became a platform with a competition engine, layered rate limiting, configurable trading strategies and a dashboard updated through WebSockets.

The underlying problem

Price influences an ad’s position on Binance P2P. Competing for that position means interpreting the market while respecting the operator’s limits.

The difficult part is making that logic dependable:

Each case needed investigation, a named failure mode and an explicit recovery strategy.

Architecture

  1. Frontend: dashboard and configuration
  2. Backend: engines, coordinator and scheduler
  3. External API: Binance P2P
  4. Storage: PostgreSQL
Components · architecture outlined in this note.

At the stage described here, the backend ran in Docker on a VPS. The frontend was a single-page application connected through WebSockets to show each engine’s activity.

The competition engine

Each ad had its own instance running in an independent asyncio loop. The orchestrator could create, stop and update engines when configuration changed without restarting the whole process.

Iteration cycle

  1. Preflight
  2. Fetch
  3. Parse
  4. Config
  5. Paginate
  6. Strategy
  7. Log / Emit
  8. Execute
  9. Metrics
Engine cycle · architecture outlined in this note.

Parallel fetching was an early improvement. Requests originally ran in sequence; asyncio.gather allowed independent responses to overlap. Emitting a decision to the frontend before executing an update let the dashboard show the intended action, not just its eventual result.

State machine

  1. Starting → Running
  2. Stalled → restart
  3. Disabled by system → check ad
  4. External error → cooldown
  5. Stop from any state
Engine states · architecture outlined in this note.

When the platform disabled an ad, the engine entered DISABLED_BY_SYSTEM and checked its status periodically. It could resume once the ad became available again.

A watchdog detected stalled engines when the time since the last iteration passed a configurable threshold. Automatic restarts had their own rate limit to avoid restart storms.

Rate limiting: the hardest problem

Poor rate limiting can fail unpredictably, sometimes silently. The design evolved into three independent layers with distinct responsibilities.

  1. Per ad: independent backoff
  2. Coordinator: concurrency, weight and circuit breaker
  3. HTTP client: retries with jitter
Control layers · architecture outlined in this note.

Layer 1: isolate each ad

Each ad kept its own backoff state. Repeated failures increased that delay exponentially without penalizing the other engines. A successful operation reset the counter.

The principle is the bulkhead pattern: one component’s failure should not spread to the entire system.

Layer 2: a global coordinator

A singleton coordinated requests across the process. Its key behavior was proactive backoff: slow down as accumulated API weight approached the limit rather than waiting for a platform error.

Each request passed through a circuit breaker, concurrency semaphore, token bucket and accumulated-weight check. A failed check stopped the request before it left the process.

Circuit breaker

  1. Closed: normal operation
  2. Open: block requests
  3. Half open: controlled probe
  4. Success → Closed; failure → Open
Circuit breaker · architecture outlined in this note.

Layer 3: retries with jitter

Without jitter, engines that fail together tend to retry together, creating another burst. Exponential jitter spreads the retries over time:

# Pseudocode — illustrative pattern
for attempt in range(MAX_RETRIES):
    try:
        return await execute_request()
    except RetryableError:
        base_delay = BASE * (2 ** attempt)
        jitter = random(0, base_delay)
        await sleep((base_delay + jitter) / 1000)

raise MaxRetriesExceeded()

The misleading case

An empty response can indicate throttling rather than an empty market. Treating it as valid market data could lead to an incorrect price decision.

The design counted consecutive empty responses. Crossing a threshold raised an implicit-throttling exception and activated global backoff. A short observation window helped avoid confusing a quiet market with throttling.

Trading strategies

Strategies shared a pipeline: filter the book, choose a target, calculate a price and apply the remaining policies.

  1. Filter the book
  2. Select target
  3. Calculate price
  4. Apply limits
  5. Emit action and reason
Decision pipeline · architecture outlined in this note.

TOP-1: aim for the leading position

The strategy sought the leading position in the feed. When already leading, it checked whether it could recover price without losing that position instead of continuing to lower the price. When behind, it aimed to beat the leader by the smallest permitted increment.

FOLLOW: track a chosen competitor

FOLLOW used a competitor’s nickname as its target. Its behavior when the target disappeared was configurable: hold the current price or fall back to TOP-1. Book pagination helped locate targets beyond the first API results.

Anti-ratchet: avoid a price chase

Two competing bots can keep pushing each other toward worse prices. The anti-ratchet policy checked whether a competitor had already moved in the operator’s direction while the operator was still leading. In that case, it stopped the next move.

Exchange-rate-relative pricing

Rather than absolute price bounds, the early design also allowed margins in USD converted using the current exchange rate. It recalculated the range just before the update instead of only at the start of the cycle, using a short-lived exchange-rate cache.

Different errors need different responses

Error handling needed explicit categories and recovery rules rather than a single generic retry policy.

CategoryResponse
Clock synchronizationSynchronize time, then retry
Inconsistent stateFetch fresh data, then retry
Price outside the rangeIdentify the valid range, adjust, then retry
Ad offlineEnter DISABLED_BY_SYSTEM and check periodically
Conflict between own adsCalculate an exclusion zone, adjust, then retry
Non-retryable errorLonger cooldown and an alert

A dynamic price range was especially tricky. The platform could reject a price because its permitted band had changed. Recovery tried the range in the error message first, an exchange-rate-based estimate second, and the last cached range as a final fallback.

The point was to recognize a stale range as a specific problem rather than treating every rejection alike.

Real-time observability

The frontend received WebSocket events through separate domain channels:

  1. Engine decisions
  2. Ad status
  3. Orders
  4. Metrics and errors
Operator information · architecture outlined in this note.

An engine emitted its decision before executing the update, showing intent as well as outcome. A hold also appeared with its specific reason.

The backend exposed Prometheus-compatible metrics for cycle latency, rate-limit consumption, circuit-breaker state and error categories.

Cache invalidation

After a price update, the frontend’s HTTP cache could be stale. An internal event notified a broker, invalidated the relevant Redis keys and told the frontend to fetch again. Keeping the dashboard aligned with actual state was a recurring challenge.

Scheduling

Ads could belong to groups with operating windows and explicit time zones. The scheduler periodically evaluated whether each engine should run or pause.

Priority rules resolved overlapping groups. The scheduler also created defaults for ads without an explicit configuration.

What took the most work

Rate limiting required the most iteration. The first implementation modeled limits per request rather than accumulated weight. Reaching a weight limit affected all requests in the process, not just the ad responsible. The global coordinator and proactive backoff addressed that mistake.

State consistency between frontend and backend remained a recurring concern. Event-driven cache invalidation helped close the gap.

The dashboard was a substantial part of the work. Operators needed to understand current activity, configure strategies without invalid combinations and notice orders that required attention. The competition engine alone was not enough.

What I took from it

New failure cases deserve a category and a recovery strategy, rather than another exception without context.

The most useful advice I can offer is to design rate limiting before the business engine. It is difficult to add later and damaging when it fails silently. Think about several components making requests at once, not just one request in isolation.

For the current product, read AutoP2P v2 or visit autop2p.dev.

← All notes