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:
- API rate limits need an explicit model, particularly with several active ads and short cycles.
- Errors behave differently. Some throttling conditions return empty responses instead of a conventional error code.
- In-memory state can drift from the platform’s actual price, available volume or ad status.
- Several ads on the same pair can conflict under platform rules that only become apparent during operation.
Each case needed investigation, a named failure mode and an explicit recovery strategy.
Architecture
- Frontend: dashboard and configuration
- Backend: engines, coordinator and scheduler
- External API: Binance P2P
- Storage: PostgreSQL
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
- Preflight
- Fetch
- Parse
- Config
- Paginate
- Strategy
- Log / Emit
- Execute
- Metrics
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
- Starting → Running
- Stalled → restart
- Disabled by system → check ad
- External error → cooldown
- Stop from any state
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.
- Per ad: independent backoff
- Coordinator: concurrency, weight and circuit breaker
- HTTP client: retries with jitter
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
- Closed: normal operation
- Open: block requests
- Half open: controlled probe
- Success → Closed; failure → Open
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.
- Filter the book
- Select target
- Calculate price
- Apply limits
- Emit action and reason
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.
| Category | Response |
|---|---|
| Clock synchronization | Synchronize time, then retry |
| Inconsistent state | Fetch fresh data, then retry |
| Price outside the range | Identify the valid range, adjust, then retry |
| Ad offline | Enter DISABLED_BY_SYSTEM and check periodically |
| Conflict between own ads | Calculate an exclusion zone, adjust, then retry |
| Non-retryable error | Longer 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:
- Engine decisions
- Ad status
- Orders
- Metrics and errors
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.