External Quote Service Update - Sequential Mode & Arbitrage Detection

External Quote Service Update - Sequential Mode & Arbitrage Detection

Date: January 25, 2026 Status: Production Update Version: 1.1 Parent Document: 30-QUOTE-SERVICE-ARCHITECTURE.md Related Services: external-quote-service, ts-scanner-service


Table of Contents

  1. Overview
  2. Sequential Quote Mode
  3. PairID Matching Fix
  4. Jupiter API Parameters
  5. Source-Aware Profit Thresholds
  6. Configuration Reference
  7. Files Modified
  8. Verification & Debugging

Overview

This document describes updates to the external quote service and scanner service to improve arbitrage detection accuracy:

ChangePurposeImpact
Sequential Quote ModeUse actual forward output for reverse inputMore accurate round-trip profit calculation
PairID Matching FixUse original subscription amount for both forward/reverse pairIDsProper quote pairing in scanner
onlyDirectRoutes=trueLimit to single-hop routesConsistent routing for arbitrage detection
Separate Profit ThresholdsDifferent thresholds for local vs external50 bps local, 10 bps external

Problem Statement

The original parallel quote mode estimated the reverse quote input amount using oracle prices:

PARALLEL MODE (Original):
T=0ms:    Forward quote: 1 SOL → X USDC
T=0ms:    Reverse quote: oracle_estimate(X) USDC → Y SOL  (PARALLEL)
          ↑ Oracle estimation introduces error
Result:   ~0 bps profit (but with estimation noise)

This produced inconsistent results because:

  1. Oracle-estimated amounts don’t match actual swap outputs
  2. Multi-hop routes may differ between forward/reverse
  3. Profit calculations were noisy, missing real opportunities

Solution

Sequential mode fetches forward quote first, then uses its actual output as the reverse quote input:

SEQUENTIAL MODE (New):
T=0ms:    Forward quote: 1 SOL → 14,154,466,452 lamports USDC
T=100ms:  Reverse quote: 14,154,466,452 lamports USDC → Y SOL
          ↑ Exact forward output, no estimation
Result:   Accurate round-trip profit (or loss)

Sequential Quote Mode

How It Works

The batch scheduler in external-quote-service now supports sequential paired quotes:

┌─────────────────────────────────────────────────────────────┐
│  SEQUENTIAL QUOTE FLOW                                       │
├─────────────────────────────────────────────────────────────┤
│                                                              │
│  1. FORWARD QUOTE (SOL → USDC)                              │
│     Request: inputMint=SOL, outputMint=USDC, amount=1 SOL   │
│     Response: outAmount = 14,154,466,452 (14.15 USDC)       │
│                                                              │
│  2. REVERSE QUOTE (USDC → SOL)                              │
│     Request: inputMint=USDC, outputMint=SOL,                │
│              amount=14,154,466,452  ← EXACT forward output  │
│     Response: outAmount = 9,985,000,000 (0.9985 SOL)        │
│                                                              │
│  3. ROUND-TRIP PROFIT                                        │
│     Input:  1.0000 SOL                                       │
│     Output: 0.9985 SOL                                       │
│     Profit: -15 bps (expected for same-route round-trip)    │
│                                                              │
└─────────────────────────────────────────────────────────────┘

Implementation

BatchQuoteScheduler (go/pkg/quoters/batch_scheduler.go):

// Sequential paired quote method
func (s *BatchQuoteScheduler) quotePairSequential(
    ctx context.Context,
    pair TokenPair,
    slippageBps int,
    forwardKey, reverseKey string,
) PairQuoteResult {
    result := PairQuoteResult{Pair: pair}

    // STEP 1: Forward quote (InputMint → OutputMint)
    forwardQuote, forwardErr := s.fetchJupiterQuote(
        ctx, pair.InputMint, pair.OutputMint, pair.Amount, slippageBps, forwardKey)
    result.ForwardQuote = forwardQuote
    result.ForwardError = forwardErr

    if forwardErr != nil {
        result.ReverseError = fmt.Errorf("skipped: forward quote failed")
        return result
    }

    // STEP 2: Parse ACTUAL outAmount from forward quote
    actualForwardOutput, err := strconv.ParseUint(forwardQuote.OutAmount, 10, 64)
    if err != nil {
        result.ReverseError = fmt.Errorf("parse forward output: %w", err)
        return result
    }

    // STEP 3: Reverse quote using ACTUAL forward output
    reverseQuote, reverseErr := s.fetchJupiterQuote(
        ctx, pair.OutputMint, pair.InputMint, actualForwardOutput, slippageBps, reverseKey)
    result.ReverseQuote = reverseQuote
    result.ReverseError = reverseErr

    return result
}

Configuration

Enable sequential mode via environment variable:

# external-quote-service configuration
USE_SEQUENTIAL_QUOTES=true   # Default: true

When to use each mode:

ModeUse CaseLatencyAccuracy
Sequential (default)Arbitrage detection2x API callsHigh (exact amounts)
ParallelPrice monitoring1x API call timeLower (oracle estimation)

PairID Matching Fix

Problem

Forward and reverse quotes must share the same pairId for the scanner to match them as a pair. The legacy sequential mode was using the actual forward output amount for the reverse quote’s pairID:

// BUG: Using forwardOutputAmount for pairID causes mismatch
reversePairID := pairid.Generate(outputMint, inputMint, forwardOutputAmount)  // ❌ WRONG

This caused forward and reverse quotes to have different pairIDs, preventing the scanner from detecting them as a pair for arbitrage calculation.

Solution

Always use the original subscription amount for pairID generation in both forward and reverse quotes:

// Generate pairID ONCE using original subscription amount
pairID := pairid.Generate(inputMint, outputMint, amount)  // ✅ CORRECT

// Forward quote uses pairID
forwardResponse := s.convertExternalQuoteToGRPC(bestForward, bestForwardProvider, pairID)

// Reverse quote REUSES the same pairID (don't recalculate)
reverseResponse := s.convertExternalQuoteToGRPC(bestReverse, bestReverseProvider, pairID)

How pairID Works

The pairid.Generate function creates a deterministic hash from sorted tokens + amount:

func Generate(inputMint, outputMint string, amount uint64) string {
    // Sort tokens lexicographically for consistency
    token1, token2 := inputMint, outputMint
    if inputMint > outputMint {
        token1, token2 = outputMint, inputMint
    }

    // Hash: SHA256(token1|token2|amount)[:16]
    data := fmt.Sprintf("%s|%s|%d", token1, token2, amount)
    hash := sha256.Sum256([]byte(data))
    return hex.EncodeToString(hash[:])[:16]
}

Key insight: Since tokens are sorted lexicographically, pairid.Generate(SOL, USDC, amount) produces the same hash as pairid.Generate(USDC, SOL, amount). The critical requirement is that the same amount must be used for both.

Scanner Matching Logic

The TypeScript scanner matches quotes by:

// Cache key includes pairId, inputMint, and provider
const cacheKey = `${quote.pairId}:${quote.inputMint}:${quote.provider}`;

// Find reverse quotes with matching criteria
findReverseQuotesByPairId(
    pairId,            // Must match
    targetInputMint,   // Reverse input = forward output
    targetOutputMint,  // Reverse output = forward input
    targetProvider     // Same provider
)

For quotes to be matched:

  1. pairId must be identical (requires same amount in generation)
  2. Direction must be reversed (input/output swapped)
  3. Provider must match

Verification

Check logs for consistent pairIDs:

docker compose logs external-quote-service | grep -E "pairID="

Expected output (same pairID for forward and reverse):

[gRPC] ✅ SEQUENTIAL forward: So11...->EPjF... amount=1000000000 output=14154466452 pairID=a3b2c1d4 (Jupiter)
[gRPC] ✅ SEQUENTIAL reverse: EPjF...->So11... input=14154466452 output=9985000000 pairID=a3b2c1d4 (Jupiter)

Jupiter API Parameters

onlyDirectRoutes=true

Added to ensure consistent routing for arbitrage detection:

// Jupiter API URL construction
urlStr := fmt.Sprintf(
    "https://api.jup.ag/swap/v1/quote?inputMint=%s&outputMint=%s&amount=%d"+
    "&slippageBps=%d&swapMode=ExactIn&restrictIntermediateTokens=true"+
    "&onlyDirectRoutes=true",  // ← NEW: Single-hop routes only
    inputMint, outputMint, amount, slippageBps,
)

Why onlyDirectRoutes=true?

WithoutWith
Multi-hop routes (A→B→C→D)Single-hop routes only (A→B)
Different routes for forward/reverseConsistent direct swaps
Better prices but complex arbitrageSimpler, verifiable arbitrage
Route mismatch causes false positivesSame pool forward and reverse

Trade-off: May miss some arbitrage opportunities that require multi-hop routes, but eliminates false positives from route mismatches.

Complete Jupiter API Parameters

ParameterValuePurpose
inputMintToken addressSource token
outputMintToken addressDestination token
amountLamportsInput amount
slippageBps0Exact quote (no slippage tolerance)
swapModeExactInFixed input amount
restrictIntermediateTokenstrueLimit intermediate tokens
onlyDirectRoutestrueSingle-hop routes only

Source-Aware Profit Thresholds

Rationale

Local and external quotes have different characteristics:

SourceLatencyRefresh RateExpected Spread
Local (on-chain pools)<5ms10-30sHigher (cross-DEX)
External (Jupiter API)200-500ms10sLower (same-route)

External round-trip quotes through Jupiter typically show near-zero profit (or small loss) because:

  • Same route forward and reverse
  • Jupiter already finds optimal pricing
  • Fees are baked into the quotes

Configuration

Separate thresholds for local vs external arbitrage:

# ts-scanner-service environment variables
ARB_MIN_PROFIT_BPS_LOCAL=50     # 0.50% for local DEX arbitrage
ARB_MIN_PROFIT_BPS_EXTERNAL=10  # 0.10% for external (Jupiter) arbitrage
ARB_MIN_PROFIT_BPS=50           # Deprecated: fallback value

Implementation

Config Schema (ts/apps/scanner-service/src/config.ts):

export const ArbitrageConfigSchema = z.object({
    /** Minimum profit in basis points for LOCAL quotes (default: 50 bps = 0.5%) */
    minProfitBpsLocal: z.number().default(50),
    /** Minimum profit in basis points for EXTERNAL quotes (default: 10 bps = 0.1%) */
    minProfitBpsExternal: z.number().default(10),
    /** @deprecated Use minProfitBpsLocal/minProfitBpsExternal instead */
    minProfitBps: z.number().default(50),
    // ... other fields
});

Quote Handler (ts/apps/scanner-service/src/handlers/quote-stream.ts):

// Determine quote source
const isExternal = forward.provider === 'Jupiter' ||
                  forward.bestSource === 'QUOTE_SOURCE_EXTERNAL';

// Apply appropriate threshold
const minProfitThreshold = isExternal
    ? this.minProfitBpsExternal  // 10 bps
    : this.minProfitBpsLocal;    // 50 bps

const validation = validateArbitrageOpportunity(profit, {
    minProfitBps: minProfitThreshold,
    // ...
});

Threshold Selection Logic

┌─────────────────────────────────────────────────────────────┐
│  THRESHOLD SELECTION                                         │
├─────────────────────────────────────────────────────────────┤
│                                                              │
│  Quote received                                              │
│       ↓                                                      │
│  Is provider "Jupiter"?  ─────────────────┐                 │
│       │                                    │                 │
│       │ NO                                 │ YES             │
│       ↓                                    ↓                 │
│  Is bestSource "QUOTE_SOURCE_EXTERNAL"?   Use 10 bps        │
│       │                                    threshold        │
│       │ NO                                                  │
│       ↓                                                      │
│  Use 50 bps threshold (LOCAL)                               │
│                                                              │
└─────────────────────────────────────────────────────────────┘

Configuration Reference

external-quote-service (go/.env)

# Sequential vs Parallel quote mode for paired (forward+reverse) arbitrage quotes
# SEQUENTIAL (default): Forward quote first, then use actual output for reverse
# PARALLEL: Both quotes simultaneously using oracle-estimated reverse amount
USE_SEQUENTIAL_QUOTES=true

# Jupiter API configuration (shared 1 RPS limit)
QUOTERS_USE_JUPITER=true
JUPITER_RATE_LIMIT=0.16

# Other quoters (independent rate limits)
QUOTERS_USE_DFLOW=false
QUOTERS_USE_OKX=false

ts-scanner-service (ts/.env)

# Arbitrage Configuration - separate thresholds for local vs external
ARB_MIN_PROFIT_BPS_LOCAL=50      # 0.50% minimum for local DEX quotes
ARB_MIN_PROFIT_BPS_EXTERNAL=10   # 0.10% minimum for external (Jupiter) quotes

# Deprecated (kept for backward compatibility)
ARB_MIN_PROFIT_BPS=50

# Other arbitrage settings
ARB_MAX_SLIPPAGE_BPS=50
ARB_MIN_CONFIDENCE=0.8
ARB_DEDUP_WINDOW_MS=1000

docker-compose.yml

external-quote-service:
  environment:
    - USE_SEQUENTIAL_QUOTES=${USE_SEQUENTIAL_QUOTES:-true}

ts-scanner-service:
  environment:
    - ARB_MIN_PROFIT_BPS_LOCAL=${ARB_MIN_PROFIT_BPS_LOCAL:-50}
    - ARB_MIN_PROFIT_BPS_EXTERNAL=${ARB_MIN_PROFIT_BPS_EXTERNAL:-10}
    - ARB_MIN_PROFIT_BPS=${ARB_MIN_PROFIT_BPS:-50}

Files Modified

Go Files

FileChanges
go/pkg/quoters/batch_scheduler.goAdded quotePairSequential(), useSequentialQuotes flag, onlyDirectRoutes=true
go/internal/external-quote-service/grpc/server.goPairID fix: Use single pairID for both forward and reverse quotes in pollSequentialQuotes()
go/cmd/external-quote-service/main.goWired USE_SEQUENTIAL_QUOTES to batch scheduler
go/.env.exampleDocumented USE_SEQUENTIAL_QUOTES

TypeScript Files

FileChanges
ts/apps/scanner-service/src/config.tsAdded minProfitBpsLocal, minProfitBpsExternal to schema and loader
ts/apps/scanner-service/src/handlers/quote-stream.tsSource-aware threshold selection
ts/apps/scanner-service/src/scanners/arbitrage-quote-scanner-flatbuf.tsAdded fields to ArbitrageQuoteScannerConfig interface
ts/apps/scanner-service/src/index.tsPass new config fields to scanner

Configuration Files

FileChanges
deployment/docker/docker-compose.ymlAdded ARB_MIN_PROFIT_BPS_LOCAL, ARB_MIN_PROFIT_BPS_EXTERNAL

Verification & Debugging

Verify Sequential Mode

Check external-quote-service logs for sequential mode confirmation:

docker compose logs external-quote-service | grep -i "sequential\|paired"

Expected output:

[gRPC] Paired quote mode set: SEQUENTIAL (actual forward output)

Verify Quote Flow

Look for sequential quote execution:

docker compose logs external-quote-service | grep -E "(Forward|Reverse).*inAmount"

Expected pattern (reverse inAmount matches forward outAmount):

Forward: inAmount=18000000000, outAmount=14154466452
Reverse: inAmount=14154466452, outAmount=17973000000

Verify Threshold Configuration

Check scanner initialization:

docker compose logs ts-scanner-service | grep -i "Handler initialized"

Expected output:

[QuoteStream] Handler initialized (minProfitLocal: 50 bps, minProfitExternal: 10 bps, maxSlippage: 50 bps)

Verify Threshold Application

Check validation debug logs:

docker compose logs ts-scanner-service | grep -A 1 "Validation failed"

Expected patterns:

# Local quotes use 50 bps threshold
[Arbitrage DEBUG] Validation failed: Profit -34.00 bps < minimum 50 bps
  provider=local, isExternal=false, threshold=50 bps, profitBps=-34.00

# External quotes use 10 bps threshold
[Arbitrage DEBUG] Validation failed: Profit -3.00 bps < minimum 10 bps
  provider=Jupiter, isExternal=true, threshold=10 bps, profitBps=-3.00

Expected Behavior

Quote SourceExpected ProfitThresholdOutcome
Local (cross-DEX)50-900+ bps50 bpsOpportunities detected
External (same-route)-50 to +10 bps10 bpsMostly filtered (no arb)
External (price anomaly)>10 bps10 bpsOpportunity detected

Note: External same-route round-trips typically show near-zero or negative profit. This is correct behavior - it means Jupiter is efficiently routing and there’s no arbitrage on that specific route. Cross-source arbitrage (local vs external price differences) is handled separately.


Summary

This update improves arbitrage detection accuracy through:

  1. Sequential Quote Mode: Eliminates oracle estimation errors by using actual forward output for reverse input
  2. PairID Matching Fix: Ensures forward and reverse quotes share the same pairID by always using the original subscription amount (not the forward output amount)
  3. onlyDirectRoutes=true: Ensures consistent single-hop routing for reliable arbitrage calculation
  4. Source-Aware Thresholds: Applies appropriate profit thresholds (50 bps local, 10 bps external) based on quote source

These changes result in more accurate profit calculations, proper quote pairing, and reduced false positives in arbitrage detection.


Document Version: 1.1 Last Updated: January 25, 2026 Status: Production