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
- Overview
- Sequential Quote Mode
- PairID Matching Fix
- Jupiter API Parameters
- Source-Aware Profit Thresholds
- Configuration Reference
- Files Modified
- Verification & Debugging
Overview
This document describes updates to the external quote service and scanner service to improve arbitrage detection accuracy:
| Change | Purpose | Impact |
|---|---|---|
| Sequential Quote Mode | Use actual forward output for reverse input | More accurate round-trip profit calculation |
| PairID Matching Fix | Use original subscription amount for both forward/reverse pairIDs | Proper quote pairing in scanner |
onlyDirectRoutes=true | Limit to single-hop routes | Consistent routing for arbitrage detection |
| Separate Profit Thresholds | Different thresholds for local vs external | 50 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:
- Oracle-estimated amounts don’t match actual swap outputs
- Multi-hop routes may differ between forward/reverse
- 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:
| Mode | Use Case | Latency | Accuracy |
|---|---|---|---|
| Sequential (default) | Arbitrage detection | 2x API calls | High (exact amounts) |
| Parallel | Price monitoring | 1x API call time | Lower (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:
pairIdmust be identical (requires same amount in generation)- Direction must be reversed (input/output swapped)
- 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?
| Without | With |
|---|---|
| Multi-hop routes (A→B→C→D) | Single-hop routes only (A→B) |
| Different routes for forward/reverse | Consistent direct swaps |
| Better prices but complex arbitrage | Simpler, verifiable arbitrage |
| Route mismatch causes false positives | Same 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
| Parameter | Value | Purpose |
|---|---|---|
inputMint | Token address | Source token |
outputMint | Token address | Destination token |
amount | Lamports | Input amount |
slippageBps | 0 | Exact quote (no slippage tolerance) |
swapMode | ExactIn | Fixed input amount |
restrictIntermediateTokens | true | Limit intermediate tokens |
onlyDirectRoutes | true | Single-hop routes only |
Source-Aware Profit Thresholds
Rationale
Local and external quotes have different characteristics:
| Source | Latency | Refresh Rate | Expected Spread |
|---|---|---|---|
| Local (on-chain pools) | <5ms | 10-30s | Higher (cross-DEX) |
| External (Jupiter API) | 200-500ms | 10s | Lower (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
| File | Changes |
|---|---|
go/pkg/quoters/batch_scheduler.go | Added quotePairSequential(), useSequentialQuotes flag, onlyDirectRoutes=true |
go/internal/external-quote-service/grpc/server.go | PairID fix: Use single pairID for both forward and reverse quotes in pollSequentialQuotes() |
go/cmd/external-quote-service/main.go | Wired USE_SEQUENTIAL_QUOTES to batch scheduler |
go/.env.example | Documented USE_SEQUENTIAL_QUOTES |
TypeScript Files
| File | Changes |
|---|---|
ts/apps/scanner-service/src/config.ts | Added minProfitBpsLocal, minProfitBpsExternal to schema and loader |
ts/apps/scanner-service/src/handlers/quote-stream.ts | Source-aware threshold selection |
ts/apps/scanner-service/src/scanners/arbitrage-quote-scanner-flatbuf.ts | Added fields to ArbitrageQuoteScannerConfig interface |
ts/apps/scanner-service/src/index.ts | Pass new config fields to scanner |
Configuration Files
| File | Changes |
|---|---|
deployment/docker/docker-compose.yml | Added 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 Source | Expected Profit | Threshold | Outcome |
|---|---|---|---|
| Local (cross-DEX) | 50-900+ bps | 50 bps | Opportunities detected |
| External (same-route) | -50 to +10 bps | 10 bps | Mostly filtered (no arb) |
| External (price anomaly) | >10 bps | 10 bps | Opportunity 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:
- Sequential Quote Mode: Eliminates oracle estimation errors by using actual forward output for reverse input
- PairID Matching Fix: Ensures forward and reverse quotes share the same pairID by always using the original subscription amount (not the forward output amount)
onlyDirectRoutes=true: Ensures consistent single-hop routing for reliable arbitrage calculation- 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
