Skip to content

Market Maker Service

The Market Maker Service (MMS) is a production-grade Go microservice responsible for providing automated liquidity on TradeX through the Pool Engine architecture — a multi-participant, Avellaneda-Stoikov based quoting system with per-member revenue attribution and monthly settlement.

For step-by-step operational procedures see the Market Maker SOP.

ResponsibilityDetails
QuotingA-S optimal spread, dynamically adjusted for inventory and volatility
Multi-participantMultiple liquidity providers trade under one coordinated pool per symbol
Risk isolationPer-member position limits; per-pool aggregate kill switch
Revenue sharingDaily Q-score scoring, monthly settlement with configurable split
Shadow modeMM_SHADOW_MODE=true computes quotes without placing orders
Admin panelFull CRUD + lifecycle controls at http://localhost:3001
graph TB Kafka[Kafka Market Events] --> KR[Kafka Router] KR --> PE1[PoolEngine\nBTCUSDT-PERP] KR --> PE2[PoolEngine\nETHUSDT-PERP] PE1 --> AS[A-S Engine] AS --> PRA[Pro-Rata Allocator] PRA --> M1[Member A → OrderService] PRA --> M2[Member B → OrderService] PRA --> M3[Member C → OrderService] M1 --> ME[Matching Engine] M2 --> ME M3 --> ME ME --> FillEvents[engine.event.v1] FillEvents --> KR PE1 --> PG[(PostgreSQL)] PE1 --> Prom[Prometheus]

One PoolEngine goroutine runs per symbol. It owns an event channel and processes all events single-threadedly — no locks required for the quoting loop itself. External API calls (GetState, GetOpenOrderCount, etc.) use a small sync.RWMutex for read safety.

symbol BTCUSDT-PERP
└── PoolEngine (single goroutine)
├── Aggregate member positions → scalar netPosition
├── A-S Engine computes reservation price + optimal spread
├── ProRata allocates order sizes to each member by capital weight
└── Place orders under each member's own account identity
ComponentPackageRole
PoolEngineservice/poolPer-symbol event loop and lifecycle
ASEngineservice/quotingAvellaneda-Stoikov formula
VolatilityTrackerservice/quotingRolling σ from trade stream
ProRataAllocatorservice/poolCapital-weight order splitting
PoolRiskGuardsservice/poolPer-pool kill switch + reject storm
MemberRuntimeservice/poolLive position/PnL/orders per participant
Kafka Routerprocessor/kafkaFan-out market events to pool engines
OrderService clientinfra/orderserviceHTTP: place/cancel per-member orders
SymbolConfig fieldMeaning
γ (gamma)gammaRisk aversion — higher = tighter quotes pulled closer to mid
σ (sigma)computedRolling volatility from recent trades (lookback = vol_lookback_seconds)
τ (tau)tauTime horizon in hours — how far ahead the model plans
κ (kappa)kappaDepth parameter — how quickly liquidity decays with price
qaggregatedNet position across all pool members (positive = long)
Reservation price:
r = microprice − q × γ × σ² × τ
Optimal half-spread:
δ = (γ × σ² × τ) / 2 + (1/γ) × ln(1 + γ/κ)
Quotes (level 0):
bid₀ = r − δ
ask₀ = r + δ
Additional levels (i = 1 … num_levels−1):
bid_i = bid₀ − i × level_spacing_bps
ask_i = ask₀ + i × level_spacing_bps

Spread is clamped to [min_spread_bps, max_spread_bps] before placing orders.

When the pool is long (positive q), the reservation price shifts below mid — bids are lower (discouraging more buying) and asks are lower (encouraging selling). When short, the opposite. This naturally mean-reverts inventory without widening the spread.

Every order placed carries the member’s own account_id / user_id:

client_order_id = mm-{8-char-participant-id}-{symbol}-{level}-{side}-{nanoseconds}

Fill events from the matching engine are attributed back to the originating member via an in-memory orderOwner map keyed by order ID.

┌─────────────────┐
startup │ │ POST /pools/{symbol}/enable
───────►│ PAUSED │◄─────────────────────────────────────────────┐
│ │ │
└────────┬────────┘ │
│ POST /pools/{symbol}/resume │
▼ │
┌─────────────────┐ risk breach / ┌───────────────┴─┐
│ ACTIVE │──► emergency kill ──────────►│ HALTED │
│ │ │ (manual reset) │
└────────┬────────┘ └─────────────────┘
│ POST /pools/{symbol}/pause
┌─────────────────┐
│ PAUSED │
└─────────────────┘
  • Service always starts all pools in PAUSED — quoting never auto-resumes after a restart.
  • HALTED requires human review before calling /resume. Investigate the risk event in logs / Grafana first.
TriggerCondition
Position breachAggregate pool position > max_pool_position_usdt
Loss breachAggregate pool PnL < −max_pool_loss_usdt
Reject stormToo many consecutive order rejections
Exchange haltInstrument status = HALTED received from Kafka
Stale market dataNo new order book update within staleness_threshold_ms (when enabled)

Each liquidity provider is a Participant with a global identity (user/account IDs, tier, margin). A participant can be a member of multiple symbol pools simultaneously.

TierIntended use
TIER_1Institutional — highest capital, tightest SLA
TIER_2Professional
TIER_3Retail / test
ModelBehaviour
KEEP_ALLParticipant keeps 100% of realised PnL
PERCENTAGE_SPLITPlatform retains revenue_share_pct% of gross PnL; remainder paid to participant
Monthly job creates record
▼ status = PENDING
Admin reviews fills + Q-score
┌────┴────┐
│ │
APPROVE REJECT (with reason)
▼ status = APPROVED
Finance executes wallet transfer
▼ status = SETTLED (or FAILED)

Every 10 seconds (configurable via PERFORMANCE_SNAPSHOT_INTERVAL) a snapshot captures each member’s:

  • uptime % (ratio of active snapshots to total)
  • avg spread bps
  • avg depth (USDT size at best levels)
  • trade count and volume

Q-score is a composite of these, weighted to reward tight spreads and high uptime. The leaderboard at GET /v1/performance/leaderboard ranks participants across all symbols.

TopicPurpose
md.orderbook.snap.v1Full order book snapshot on subscribe
md.orderbook.delta.v1Incremental order book updates
md.mark.v1Mark price for perpetuals
engine.event.v1Fill / reject / cancel events from matching engine
TopicWhen
mm.state.changed.v1Any pool state transition
mm.risk.triggered.v1Kill switch activation
mm.quote.simulated.v1Shadow mode only

All events are Avro-encoded; schemas live in shared/kafka-schema/market-maker-service/.

TablePurpose
mm_pool_configsA-S strategy config per symbol
mm_participantsEnrolled liquidity providers
mm_pool_membersParticipant ↔ symbol assignments with weights
mm_ordersOpen and historical MM orders
mm_fillsFill history with per-member attribution
mm_performance_dailyAggregated daily Q-score metrics per participant × symbol
mm_settlementsMonthly revenue-sharing payout records
outboxTransactional outbox for Kafka publishing
VariableDefaultDescription
POSTGRES_URLRequired — PostgreSQL connection string
REDIS_URLRequired — Redis connection string
KAFKA_BROKERSlocalhost:9092Kafka broker list
SCHEMA_REGISTRY_URLRequired — Confluent Schema Registry
ORDER_SERVICE_URLhttp://order-service:3000Order Service HTTP base URL
METADATA_SERVICE_GRPC_URLlocalhost:50051Metadata Service gRPC URL
HTTP_PORT8095REST API port
METRICS_PORT9090Prometheus metrics port
MM_SHADOW_MODEfalseCompute quotes, place no orders
PERFORMANCE_SNAPSHOT_INTERVAL10sQ-score snapshot cadence
SHUTDOWN_TIMEOUT30sGraceful shutdown window
AUTH_SERVICE_URLhttp://auth-service:3000JWT validation endpoint
DISABLE_AUTHfalseDev only — skip JWT validation

Pool config fields (per symbol, stored in DB)

Section titled “Pool config fields (per symbol, stored in DB)”
FieldTypeDescription
symbolstringTrading symbol, e.g. BTCUSDT-PERP
enabledboolWhether the pool should auto-start on service restart
gammadecimalRisk-aversion coefficient (A-S γ)
kappadecimalMarket depth parameter (A-S κ)
vol_lookback_secondsintRolling window for σ estimation
taudecimalTime horizon in hours (A-S τ)
num_levelsintNumber of price levels per side
level_spacing_bpsdecimalBasis-point gap between levels
min_spread_bpsdecimalFloor for computed spread
max_spread_bpsdecimalCeiling for computed spread
order_size_usdtdecimalTotal pool order size per level
max_pool_position_usdtdecimalAggregate position limit (kill switch trigger)
max_pool_loss_usdtdecimalAggregate loss limit (kill switch trigger)
check_stalenessboolHalt quoting when market data is stale
staleness_threshold_msdecimalMax age of last order book update
quote_interval_msdecimalMinimum re-quote cadence
price_threshold_bpsdecimalMinimum price move to trigger a re-quote

All routes are prefixed /v1.

# Pool lifecycle
GET /pools — list all pool configs
POST /pools — create or update (upsert) a pool config
GET /pools/{symbol} — get config for one symbol
PUT /pools/{symbol} — update config
DELETE /pools/{symbol} — delete config (pool must be paused)
POST /pools/{symbol}/enable — PAUSED → ACTIVE
POST /pools/{symbol}/pause — ACTIVE → PAUSED
POST /pools/{symbol}/resume — PAUSED → ACTIVE (after manual review)
GET /pools/status — all pool live statuses
GET /pools/{symbol}/status — one pool live status + member breakdown
# Pool members
GET /pools/{symbol}/members — list members
POST /pools/{symbol}/members — add member to pool
PUT /pools/{symbol}/members/{id} — update weight / limits
DELETE /pools/{symbol}/members/{id} — remove member
# Participants
GET /participants — list all
POST /participants — enroll new participant (PENDING)
GET /participants/{id} — get participant
PUT /participants/{id} — update mutable fields
POST /participants/{id}/activate — PENDING → ACTIVE
POST /participants/{id}/suspend — ACTIVE → SUSPENDED
POST /participants/{id}/terminate — permanent removal
GET /participants/{id}/memberships — cross-symbol memberships
# Performance
GET /performance/leaderboard — Q-score ranking
GET /performance/symbol/{symbol} — per-symbol daily perf
GET /performance/{participantId} — per-participant daily perf
# Settlements
GET /settlements — list all settlements
GET /settlements/pending — pending approval queue
GET /settlements/{id} — get settlement
POST /settlements/{id}/approve — approve (body: {approved_by})
POST /settlements/{id}/reject — reject (body: {rejected_by, reason})
# Orders & fills (read-only)
GET /mm-orders — all MM orders
GET /mm-orders/{symbol} — orders for one symbol
GET /mm-orders/{symbol}/breakdown — by side/status
GET /mm-fills — all fills
GET /mm-fills/{symbol} — fills for one symbol
GET /mm-fills/{symbol}/summary — 24h aggregate
# Analytics
GET /mm-analytics/overview — 24h totals across all symbols
GET /mm-analytics/hourly — hourly buckets (?symbol= to filter)
GET /mm-analytics/{symbol}/metrics — full symbol metrics
# Emergency controls
POST /mm/emergency/kill — pause ALL pool engines
POST /mm/emergency/flatten — pause ALL pool engines + cancel orders
POST /mm/reset — stub: directs to /pools/{symbol}/resume
GET /mm/status — summary across all engines
MetricLabelsDescription
mm_pool_member_countsymbolActive member count per pool
mm_pool_net_positionsymbol, participant_idPer-member net position (USDT)
mm_pool_member_pnlsymbol, participant_idRealised PnL per member
mm_pool_member_inventorysymbol, participant_idInventory units
mm_pool_member_open_orderssymbol, participant_idOpen order count
mm_pool_q_scoresymbol, participant_idLatest Q-score
mm_pool_quote_compute_latency_secondssymbolHistogram of A-S compute time
mm_pool_allocation_latency_secondssymbolHistogram of pro-rata allocation time
mm_settlement_countstatusSettlement count by status
mm_performance_snapshot_totalsymbolTotal snapshots taken
mm_performance_rollup_totalsymbolTotal daily rollups

OpenTelemetry traces exported via OTLP to OpenObserve (OPENOBSERVE_OTLP_ENDPOINT).

Structured JSON via zap. Every log entry includes symbol, participant_id (where applicable), and a trace_id for cross-service correlation.

MMS never:

  • ❌ Mutates order books directly
  • ❌ Bypasses the Order Service
  • ❌ Accesses user wallets or positions
  • ❌ Uses privileged matching logic
  • ❌ Places orders when state ≠ ACTIVE
  • ❌ Auto-resumes after a kill switch

MMS always:

  • ✅ Cancels all orders when transitioning to PAUSED or HALTED
  • ✅ Routes all orders through Order Service REST API
  • ✅ Tags orders with member’s own account_type = SYSTEM_MARKET_MAKER
  • ✅ Starts in PAUSED state on every boot
  • ✅ Validates tick size and lot size from Metadata Service before quoting