VeztaVezta
Guides

Adding a Market Source

How to build a new exchange connector

Vezta currently aggregates markets from Polymarket and Kalshi (via DFlow on Solana). This guide walks through adding a new prediction market exchange as a data source.

The existing reference implementations are PolymarketConnector, DflowConnector, PolymarketWalletConnector, and the WebSocket connectors PolymarketWsConnector, DflowWsConnector, PolymarketSportsWsConnector, and PolymarketRtdsConnector.

Architecture Overview

Market data flows through a three-layer pipeline:

All connectors live in src/ingester/ and are registered in IngesterModule. The existing connectors — PolymarketConnector, DflowConnector, and PolymarketWalletConnector — serve as reference implementations.

Connector Interface

Every connector must implement methods for fetching markets and order books. The interface is defined in src/ingester/exchange-connector.interface.ts:

export interface ExchangeConnector {
  name: string;
  fetchMarkets(cursor?: string): Promise<unknown[]>;
  fetchOrderBook(
    marketId: string,
  ): Promise<{ bids?: OrderBookLevel[]; asks?: OrderBookLevel[] }>;
}

Where OrderBookLevel is:

export interface OrderBookLevel {
  price: string;
  size: string;
}

Implementation Steps

Define Raw Types

Create a types file for the exchange's raw API response shapes in src/ingester/types/. Each exchange has different field names, casing conventions, and data formats.

// src/ingester/types/newexchange.types.ts
export interface NewExchangeRawMarket {
  id: string;
  title: string;
  category: string;
  yes_price: string;
  no_price: string;
  volume: string;
  close_date: string;
  // ... exchange-specific fields
}

Reference src/ingester/exchange-connector.interface.ts for the Polymarket and Kalshi raw types as examples.

Create the Connector

Build a connector class in src/ingester/ that fetches data from the exchange's API. Use the @Injectable() decorator for NestJS dependency injection.

// src/ingester/newexchange.connector.ts
import { Injectable, Logger } from '@nestjs/common';

@Injectable()
export class NewExchangeConnector {
  private readonly logger = new Logger(NewExchangeConnector.name);
  private readonly baseUrl = 'https://api.newexchange.com';

  async fetchMarkets(cursor?: string): Promise<NewExchangeRawMarket[]> {
    // Fetch and return raw market data
  }

  async fetchOrderBook(marketId: string) {
    // Fetch and return order book levels
  }

  async fetchPrices(marketIds: string[]) {
    // Fetch current prices for active markets
  }
}

Follow the patterns in polymarket.connector.ts:

  • Use a fetch timeout (e.g., 10 seconds)
  • Return empty arrays on failure instead of throwing
  • Log warnings for non-200 responses
  • Handle pagination if the exchange API requires it

Normalize to the Unified Model

The NormalizerService (in the market module) maps raw exchange data to Prisma Market records. Add a normalization method for your exchange that maps exchange-specific fields to the unified schema:

  • source -- exchange identifier (e.g., 'polymarket', 'kalshi', 'newexchange')
  • externalId -- the exchange's unique market identifier
  • title, description, category -- market metadata
  • yesPrice, noPrice -- current prices (as Decimal)
  • volume, volume24h, liquidity -- trading metrics
  • endDate -- market resolution date
  • startedAt -- when the market opened on the exchange
  • imageUrl -- market thumbnail

Register in IngesterModule

Add the connector to src/ingester/ingester.module.ts:

@Module({
  providers: [
    PolymarketConnector,
    DflowConnector,
    PolymarketWalletConnector,
    NewExchangeConnector,  // Add here
  ],
  exports: [
    PolymarketConnector,
    DflowConnector,
    PolymarketWalletConnector,
    NewExchangeConnector,  // And here
  ],
})
export class IngesterModule {}

Add Sync Jobs

Create BullMQ jobs in the market module for the new exchange. The existing sync jobs run on the market-sync queue:

  • Full sync -- fetch all markets (every 5 minutes)
  • Price sync -- fetch current prices for active markets (every 60 seconds)
  • Orderbook sync -- fetch order books for popular markets (every 60 seconds)

Follow the pattern in MarketSyncScheduler: implement OnModuleInit, clean old repeatable jobs, then register new ones.

Add Trading Adapter (Optional)

If the exchange supports order placement, create a trading adapter in src/modules/trading/adapters/ that implements order submission and cancellation. The SmartRouterService selects the appropriate adapter based on the market's source. Adapters must:

  • Honor the per-exchange circuit breaker (CircuitBreakerService)
  • Surface a clear error code via ApiException so the global filter can normalize it
  • Handle their own retry strategy (existing adapters do 3x exponential backoff with no retry on 4xx)

For exchanges with non-custodial signing (like DFlow on Solana), return an unsigned transaction from the adapter and let the frontend handle signing -- the backend should never hold user keys.

Tag the Worker Domain

New schedulers/processors need to declare their worker domain via backgroundOnly([Processor, Scheduler], 'market'). Market connectors generally live on the market worker. See vezta-be/plans/backend-optimization/phase-4-specialized-workers.md.

Always cap event/market fetching to avoid overloading the shared VM. Polymarket and DFlow connectors both cap at 200 events. Uncapped fetching on the staging VM (8 GiB) causes memory exhaustion.

Testing

Write unit tests for the connector in src/ingester/newexchange.connector.spec.ts. Use MSW v2 to mock external API calls. Reference polymarket.connector.spec.ts and dflow.connector.spec.ts for patterns:

  • Mock successful responses with realistic data
  • Test error handling (network failures, non-200 status codes)
  • Test pagination logic
  • Verify the normalized output matches the expected Market shape

On this page