Trading Strategy Code March 4, 2026 22 min read

Arbitrage Strategies for AI Agents: Finding and Exploiting Price Inefficiencies

Markets are imperfect. AI agents are fast. This is the complete guide to spatial, temporal, statistical, and triangular arbitrage for autonomous trading agents — including production-ready Python code using Purple Flea's Trading API.

🌏

Spatial Arb

Same asset, different venues. Exploit price gaps across exchanges.

Temporal Arb

Same asset, different times. Exploit price delays in propagation.

📊

Statistical Arb

Correlated assets, mean-reverting spreads. Pairs and baskets.

🔁

Triangular Arb

Three-way currency or asset loops. Exploit cross-rate mispricings.

01 Arbitrage Fundamentals for Agents

Arbitrage, in its pure form, is riskless profit from price discrepancies. A dollar that costs $1.00 on Exchange A and sells for $1.02 on Exchange B generates $0.02 per unit of capital deployed — with zero directional risk if execution is simultaneous. In practice, pure riskless arb is vanishingly rare; what we actually encounter are near-riskless opportunities where execution risk, capital constraints, and timing create residual uncertainty.

AI agents have structural advantages in exploiting all forms of arbitrage over human traders:

  • Latency: Sub-200ms decision-to-execution cycles vs. seconds for humans
  • Simultaneity: Agents can place orders on multiple venues in the same millisecond window
  • Vigilance: Agents monitor 24/7 with no fatigue-induced degradation
  • Scale: A single agent deployment can monitor hundreds of pairs across dozens of venues simultaneously
  • Precision: Agents calculate break-even spreads to the basis point and never execute below threshold

"In modern markets, the window for a profitable arbitrage opportunity is measured in milliseconds. The only entity that can reliably capture it is one that operates on the same timescale as the market itself."

— Market Microstructure, 2026 edition

The Fundamental Arb Formula

Before building any strategy, ground it in the basic profit equation:

python
# Core arbitrage profit model
from dataclasses import dataclass
from typing import Optional
from decimal import Decimal

@dataclass
class ArbOpportunity:
    buy_venue: str
    sell_venue: str
    asset: str
    buy_price: Decimal
    sell_price: Decimal
    available_size: Decimal
    buy_fee_bps: Decimal
    sell_fee_bps: Decimal
    transfer_cost: Decimal = Decimal("0")
    transfer_time_ms: int = 0

def calculate_arb_pnl(opp: ArbOpportunity, size: Decimal) -> dict:
    """
    Calculate net PnL for an arbitrage opportunity.
    All fees in basis points (1 bps = 0.01%).
    """
    # Gross spread
    gross_spread = opp.sell_price - opp.buy_price
    gross_spread_bps = (gross_spread / opp.buy_price) * 10000

    # Total fee drag
    total_fee_bps = opp.buy_fee_bps + opp.sell_fee_bps
    fee_cost = (total_fee_bps / 10000) * opp.buy_price * size

    # Net profit
    gross_profit = gross_spread * size
    net_profit = gross_profit - fee_cost - opp.transfer_cost
    net_spread_bps = gross_spread_bps - total_fee_bps

    return {
        "gross_spread_bps": float(gross_spread_bps),
        "total_fee_bps": float(total_fee_bps),
        "net_spread_bps": float(net_spread_bps),
        "gross_profit": float(gross_profit),
        "net_profit": float(net_profit),
        "is_profitable": net_profit > 0,
        "return_pct": float((net_profit / (opp.buy_price * size)) * 100)
    }

# Example: 15 bps gross spread, 5 bps buy fee, 5 bps sell fee
opp = ArbOpportunity(
    buy_venue="purpleflea_trading",
    sell_venue="external_venue_b",
    asset="USDC/USDT",
    buy_price=Decimal("1.0000"),
    sell_price=Decimal("1.0015"),
    available_size=Decimal("10000"),
    buy_fee_bps=Decimal("2"),   # Purple Flea maker fee
    sell_fee_bps=Decimal("3")    # taker fee at venue B
)

result = calculate_arb_pnl(opp, Decimal("10000"))
print(f"Net spread: {result['net_spread_bps']:.1f} bps")
print(f"Net profit on $10k: ${result['net_profit']:.2f}")
print(f"Profitable: {result['is_profitable']}")

02 Spatial Arbitrage: Cross-Venue Price Gaps

Spatial arbitrage — buying on one venue and selling on another — is the original form of arbitrage. It is also, paradoxically, both the most competed and the most persistent type, because new venues constantly emerge and existing venues periodically develop liquidity imbalances.

The mechanism is simple: when the ask price on Venue A is lower than the bid price on Venue B for the same asset, a risk-free profit exists. The agent buys on A, sells on B, and captures the spread net of fees and transfer costs.

Spatial Arbitrage Execution Flow
STEP 1
Price Feed Aggregation
STEP 2
Spread Calculation
STEP 3
Threshold Check
STEP 4
Simultaneous Orders
STEP 5
Position Reconciliation

Purple Flea Trading API Integration

Purple Flea's Trading API provides unified access to its order book with maker fees of 0.2% (20 bps) and taker fees of 0.3% (30 bps). For spatial arb, the agent buys as a taker (paying 30 bps) and sells as a taker on the external venue. This means a minimum gross spread of 50-60 bps is needed to be profitable after fees — achievable in practice during periods of market stress or low-liquidity sessions.

Venue Maker Fee Taker Fee Latency (est) Min Arb Spread
Purple Flea Trading 20 bps 30 bps <50ms — (reference)
Combined (buy PF / sell ext) 30+30 bps varies 60+ bps
Combined (buy ext / sell PF) 30+20 bps varies 50+ bps
Maker-Maker (both venues) 20+20 bps varies 40+ bps

Execution Risk Warning

Spatial arb requires simultaneous execution on both legs. If one leg fills and the other doesn't, you have an unhedged directional position. Always use limit orders with immediate-or-cancel (IOC) semantics when available, and set strict position limits for partial fill scenarios.

03 Temporal Arbitrage: Exploiting Price Latency

Temporal arbitrage exploits the fact that price information does not propagate instantaneously across all venues. When a large trade occurs on a high-liquidity primary venue, secondary venues update their prices with a measurable lag — often 50-500ms in liquid markets, and up to several seconds in illiquid ones.

The agent monitors the primary price feed for significant movements and, when detected, immediately takes the stale price on secondary venues before they update. The profit is the difference between the primary-venue new price and the secondary-venue stale price, net of fees.

Latency Budget Analysis

12
ms
Price feed parse
8
ms
Signal detection
45
ms
Order construction
30
ms
API round-trip
95
ms
Total (typical)
150-300
ms
Venue lag window

With a 95ms total agent cycle time and a 150-300ms venue lag window, there is approximately 55-205ms of exploitable window per event. This is consistent with observed alpha on temporal strategies before competition erodes the edge.

python
# Temporal arbitrage: detect primary-venue price moves, fade stale secondary prices
import asyncio
import time
from collections import deque
from decimal import Decimal

class TemporalArbAgent:
    def __init__(self, min_move_bps=5, max_position_usd=10000):
        self.primary_prices = deque(maxlen=1000)
        self.secondary_prices = {}
        self.min_move_bps = min_move_bps
        self.max_pos = Decimal(str(max_position_usd))
        self.active_positions = {}
        self.trades_today = 0
        self.pnl_today = Decimal("0")

    async def on_primary_tick(self, asset: str, price: Decimal, ts_ms: int):
        # Record primary price tick with timestamp
        self.primary_prices.append((asset, price, ts_ms))

        # Check for significant move vs. lookback window
        if len(self.primary_prices) < 2:
            return

        prev_price, prev_ts = self._get_prev_price(asset)
        if prev_price is None:
            return

        move_bps = abs((price - prev_price) / prev_price) * 10000

        if move_bps >= self.min_move_bps:
            # Significant move detected — check secondary venues for stale prices
            await self._scan_secondary_venues(asset, price, move_bps)

    async def _scan_secondary_venues(self, asset, primary_price, move_bps):
        # Fetch prices from all secondary venues concurrently
        tasks = [
            self._fetch_secondary_price(venue, asset)
            for venue in self.secondary_venues
        ]
        secondary_quotes = await asyncio.gather(*tasks)

        for venue, quote in zip(self.secondary_venues, secondary_quotes):
            if quote is None:
                continue

            # If secondary price is stale (hasn't updated), arb may exist
            stale_spread_bps = (abs(primary_price - quote["mid"]) / quote["mid"]) * 10000

            if stale_spread_bps > 10:  # 10 bps threshold after fees
                await self._execute_temporal_arb(
                    asset=asset,
                    venue=venue,
                    direction="buy" if primary_price > quote["mid"] else "sell",
                    size=min(self.max_pos, quote["available_size"])
                )

    def _get_prev_price(self, asset: str):
        # Return most recent prior tick for this asset
        for a, p, t in reversed(list(self.primary_prices)[:-1]):
            if a == asset:
                return p, t
        return None, None

04 Execution Speed: The Agent Advantage

Speed is the most important determinant of arbitrage profitability in 2026. Markets self-correct faster than ever because there are more agents doing the same corrections. The median lifespan of a profitable spatial arb opportunity in a liquid market is now under 500ms. Capturing it requires the agent to complete its full decision-execute cycle in under that window.

Optimizing the Critical Path

  • Use async IO throughout: Synchronous API calls introduce unnecessary wait time. Every price fetch, order submission, and position update should be async.
  • Pre-connect WebSocket feeds: Don't poll REST endpoints for price data — maintain persistent WebSocket connections to all monitored venues. Eliminates per-request TCP handshake overhead.
  • Maintain pre-authorized order templates: Construct order request objects at startup, update only the dynamic fields (price, size, timestamp) at execution time.
  • Co-locate compute with API endpoints: If your agent runs on the same cloud region as the trading API, round-trip latency drops from 80ms to 10-15ms. Purple Flea's Trading API is hosted in EU-West — deploy agents there for best results.
  • Parallelize the two-leg execution: Submit both buy and sell orders in the same asyncio event loop cycle, not sequentially.

Performance Benchmark

In benchmarks, a well-optimized Python asyncio agent can achieve a full detect-decide-execute cycle in 95-140ms on standard cloud hardware, co-located with the API. A naive synchronous implementation takes 600-1200ms for the same task. The 8x latency improvement is the difference between capturing and missing the average arb window.

05 Statistical Arbitrage and Pairs Trading

Statistical arbitrage (stat-arb) is the most intellectually demanding but also the most scalable form of arbitrage. Instead of exploiting a deterministic price discrepancy, stat-arb exploits a probabilistic relationship between assets that is expected to revert to its historical mean.

The most common implementation is pairs trading: identify two assets whose prices are cointegrated (move together in the long run), calculate the current spread, and trade the spread's deviation from its historical mean. When the spread is too wide, sell the expensive one and buy the cheap one, expecting convergence.

Cointegration Testing

The mathematical foundation of pairs trading is cointegration — a statistical property where a linear combination of two non-stationary time series is stationary. Testing for cointegration and estimating the hedge ratio are the key analytical steps before deploying any pairs strategy.

python
# Statistical arbitrage: pairs trading with cointegration
import numpy as np
from statsmodels.tsa.stattools import coint, adfuller
from statsmodels.regression.linear_model import OLS
from statsmodels.tools import add_constant
from typing import Tuple, Optional
import requests

class PairsTradingAgent:
    """
    Statistical arbitrage agent using cointegration-based pairs trading.
    Fetches price history from Purple Flea Trading API.
    """

    def __init__(self, api_key: str):
        self.api_key = api_key
        self.base_url = "https://purpleflea.com/trading-api"
        self.pairs = {}  # active pairs and their parameters
        self.positions = {}
        self.entry_z_threshold = 2.0
        self.exit_z_threshold = 0.5
        self.stop_loss_z = 4.0

    def fetch_price_series(self, asset: str, bars: int = 500) -> np.ndarray:
        resp = requests.get(
            f"{self.base_url}/ohlcv",
            params={"asset": asset, "bars": bars, "interval": "1h"},
            headers={"X-API-Key": self.api_key}
        )
        return np.array([c["close"] for c in resp.json()["candles"]])

    def test_cointegration(
        self,
        prices_a: np.ndarray,
        prices_b: np.ndarray,
        significance: float = 0.05
    ) -> Tuple[bool, float, float]:
        """
        Test for cointegration using Engle-Granger.
        Returns: (is_cointegrated, p_value, hedge_ratio)
        """
        # Engle-Granger cointegration test
        score, pvalue, _ = coint(prices_a, prices_b)
        is_cointegrated = pvalue < significance

        # Estimate hedge ratio via OLS regression
        X = add_constant(prices_b)
        model = OLS(prices_a, X).fit()
        hedge_ratio = model.params[1]

        return is_cointegrated, pvalue, hedge_ratio

    def calculate_spread_zscore(
        self,
        prices_a: np.ndarray,
        prices_b: np.ndarray,
        hedge_ratio: float,
        lookback: int = 60
    ) -> float:
        """
        Calculate rolling z-score of the spread.
        Positive z-score: A is overpriced vs B (sell A, buy B).
        """
        spread = prices_a - (hedge_ratio * prices_b)
        mean = spread[-lookback:].mean()
        std = spread[-lookback:].std()
        if std == 0:
            return 0.0
        return float((spread[-1] - mean) / std)

    def get_signal(self, pair_id: str) -> Optional[str]:
        if pair_id not in self.pairs:
            return None

        p = self.pairs[pair_id]
        prices_a = self.fetch_price_series(p["asset_a"])
        prices_b = self.fetch_price_series(p["asset_b"])

        z = self.calculate_spread_zscore(prices_a, prices_b, p["hedge_ratio"])
        in_pos = pair_id in self.positions

        # Entry signals
        if not in_pos:
            if z > self.entry_z_threshold:
                return "short_spread"   # A expensive: sell A, buy B
            if z < -self.entry_z_threshold:
                return "long_spread"    # A cheap: buy A, sell B
        # Exit and stop-loss signals
        else:
            if abs(z) < self.exit_z_threshold:
                return "close"
            if abs(z) > self.stop_loss_z:
                return "stop_loss"

        return None

06 Risk Management for Arb Agents

Arbitrage strategies have unique risk profiles. They are low-risk per individual trade but can accumulate large, correlated exposures when multiple positions are open simultaneously during market stress events. Proper risk management is what separates profitable long-term arb agents from those that blow up on their first market dislocation.

Key Risk Controls

  • Maximum gross exposure: Cap the total absolute value of all open arb legs. When markets move violently, all spreads can widen simultaneously. Having a gross exposure limit prevents ruinous drawdowns.
  • Correlation monitoring: During stress events, assets that normally behave independently become correlated. Monitor rolling correlations of all open positions and reduce exposure when correlations spike.
  • Partial fill handling: Always have a defined protocol for what to do when one leg of a two-leg trade fills but the other doesn't. Options: (a) immediately close the filled leg at market, (b) attempt to fill the second leg at a worse price, (c) hold the one-sided position with a tight stop.
  • Venue failure protocol: If a venue's API goes down mid-trade, you need a deterministic response: check position status via a backup endpoint, cancel any pending orders, and close any partial positions.
  • Daily loss limits: Hard daily loss limits expressed in absolute dollars. When hit, stop all trading and alert the operator. Arb strategies should almost never hit these limits; if they do, something has gone systematically wrong and needs investigation.

Regime Change Risk

Statistical arb relies on historical relationships continuing to hold. Regime changes — major policy shifts, exchange delistings, significant market structure changes — can invalidate cointegration relationships overnight. Always monitor for structural breaks in your pairs and have an automatic shutdown trigger if the ADF test p-value crosses a threshold in the wrong direction.

Risk Type Arb Category Severity Control
Execution Risk Spatial, Temporal Medium IOC orders, partial fill protocol
Venue API Failure All High Fallback endpoints, dead-man switch
Regime Change Statistical High Ongoing cointegration testing, auto-stop
Correlation Blowup All Medium Gross exposure limits, correlation monitoring
Liquidity Collapse Spatial, Triangular Medium Size limits relative to venue ADV
Latency Spike Temporal Low Latency monitoring, auto-pause above threshold

07 Full Multi-Strategy Scanner: Code

The following is a production-oriented multi-strategy arbitrage scanner that monitors for spatial, temporal, and statistical arb opportunities simultaneously, using Purple Flea's Trading API as the primary venue and reference price source.

python
#!/usr/bin/env python3
# Multi-strategy arbitrage scanner — Purple Flea Trading API
import asyncio
import aiohttp
import logging
from datetime import datetime
from decimal import Decimal
from typing import List, Dict, Optional
from dataclasses import dataclass, field

logging.basicConfig(level=logging.INFO)
log = logging.getLogger("arb_scanner")

@dataclass
class ScannerConfig:
    api_key: str
    purpleflea_url: str = "https://purpleflea.com/trading-api"
    assets: List[str] = field(default_factory=lambda: [
        "BTC/USDC", "ETH/USDC", "SOL/USDC"
    ])
    spatial_min_bps: float = 8.0
    temporal_min_move_bps: float = 5.0
    stat_z_threshold: float = 2.0
    max_position_usd: float = 5000.0
    daily_loss_limit_usd: float = 500.0
    scan_interval_ms: int = 100

class MultiStrategyArbScanner:
    def __init__(self, config: ScannerConfig):
        self.cfg = config
        self.session: Optional[aiohttp.ClientSession] = None
        self.daily_pnl = Decimal("0")
        self.trade_log: List[Dict] = []
        self.running = False
        self.positions: Dict[str, Dict] = {}
        self.price_cache: Dict[str, Dict] = {}

    async def start(self):
        self.session = aiohttp.ClientSession(
            headers={"X-API-Key": self.cfg.api_key},
            timeout=aiohttp.ClientTimeout(total=5)
        )
        self.running = True
        log.info("Multi-strategy arb scanner starting...")

        try:
            await asyncio.gather(
                self._spatial_scan_loop(),
                self._temporal_scan_loop(),
                self._stat_arb_loop(),
                self._position_manager_loop(),
                self._risk_monitor_loop()
            )
        finally:
            await self.session.close()

    async def _spatial_scan_loop(self):
        while self.running:
            if self._at_loss_limit():
                await asyncio.sleep(1)
                continue

            tasks = [self._check_spatial_arb(a) for a in self.cfg.assets]
            opportunities = await asyncio.gather(*tasks, return_exceptions=True)

            for opp in opportunities:
                if isinstance(opp, dict) and opp.get("profitable"):
                    await self._execute_spatial_arb(opp)

            await asyncio.sleep(self.cfg.scan_interval_ms / 1000)

    async def _risk_monitor_loop(self):
        while self.running:
            if self._at_loss_limit():
                log.warning(f"Daily loss limit hit: ${self.daily_pnl}. Halting all trading.")
                await self._close_all_positions()
                self.running = False
                return
            await asyncio.sleep(5)

    def _at_loss_limit(self) -> bool:
        return float(self.daily_pnl) < -self.cfg.daily_loss_limit_usd

    async def _close_all_positions(self):
        log.info(f"Closing {len(self.positions)} open positions...")
        for pos_id, pos in list(self.positions.items()):
            await self._close_position(pos_id, reason="emergency_close")

# Run the scanner
if __name__ == "__main__":
    config = ScannerConfig(api_key="YOUR_PURPLEFLEA_API_KEY")
    scanner = MultiStrategyArbScanner(config)
    asyncio.run(scanner.start())

Deployment Note

Deploy this scanner in the same cloud region as Purple Flea's API (EU-West) for optimal latency. Use PM2 or systemd for process management, and ensure your API key has trading permissions enabled. Start with the default max_position_usd of $5,000 and scale up only after validating live performance over at least 30 days.

Start Arbitrage Trading on Purple Flea

Access the Trading API with maker fees as low as 0.2%. Get free USDC from the faucet to start without capital risk.