Skip to main content

USD.cav: Competing on Depth Without Exhausting Inventory

· 27 min read
Eric Forgy
Founder of CavalRe

USD.cav needs to stand on its own as a market for dollar-denominated assets. Its role as a connector to other pools matters, but the first question is whether traders receive competitive quotes while the pool retains enough inventory to keep making markets.

The first historical experiment identifies a useful boundary: scale elasticity es=0.995e_s=0.995 is too low for consistently competitive large hub trades in this three-asset comparison. At es=0.997e_s=0.997, Multiswap wins most large quotes, but a recorded DAI price spike draws down almost all of its DAI inventory. Increasing elasticity to 0.9990.999 improves quotes further and makes that inventory response more extreme.

This article shows the experiment, the charts, and the code behind that conclusion. Each venue starts with the same quantities of every asset and the same prices. Curve uses a star of two-token pools; Multiswap uses one shared reserve portfolio.

What has been established

This is a historical simple-arbitrage experiment, using Curvesim's pool implementation and trader interface. It covers 1,440 hourly observations, holds configured parameters fixed, and uses zero fees and gas. It establishes a candidate range and an inventory constraint. Volume-constrained trading and fee-aware production calibration remain unfinished.

This revision replaces the earlier article's isolated-pool comparisons and hypothetical paths as the basis for selecting USD.cav's elasticity. The results below are specific to a three-token universe; they are not an 18-asset calibration.

How elasticity controls a Multiswap reserve

Multiswap holds a reserve amount aia_i of each asset ii. Its scale sis_i expresses that reserve's value in the pool's common price unit. The internal price is

Pi=siai.P_i=\frac{s_i}{a_i}.

For an amount change daida_i, define ri=dai/air_i=da_i/a_i. Positive changes pay an asset into the pool; negative changes receive it from the pool. At fixed scale elasticity 0es<10\le e_s<1,

ai=ai(1+ri),si=si(1+ri)es.a_i'=a_i(1+r_i), \qquad s_i'=s_i(1+r_i)^{e_s}.

The price response follows immediately. With price elasticity eP=1ese_P=1-e_s,

PiPi=(1+ri)eP.\frac{P_i'}{P_i}=(1+r_i)^{-e_P}.

This relation can be inverted exactly:

ri=(PiPi)1/eP1.r_i=\left(\frac{P_i'}{P_i}\right)^{-1/e_P}-1.

A small ePe_P means a large reserve response can accompany a small price change. That produces attractive depth around the current price and the sharply bending response near reserve depletion.

For numerical work, the inverse is evaluated as:

r_i = math.expm1(math.log(P_i_prime / P_i) / (e_s - 1))

These are internal pool prices. External market prices enter through arbitrage. Independently inserting every external USD price into the inverse would not necessarily produce a valid swap: the changes must also close the post-trade value flow,

idaiPi=0.\sum_i da_i P_i'=0.

For a quoted swap, the implementation evaluates the pay legs, solves for the receive legs satisfying this condition, and updates the affected reserves and scales. The simulation uses those same equations. It checks the forward price response, its inverse, and value-flow closure. Curve's coupled invariant generally has no equivalent fixed per-asset power law.

The capital comparison must cover the token universe

In Multiswap, every asset is directly tradable against every other asset. A single asset's reserve supports all of its trading relationships without allocating separate balances to each pair.

A practical two-token-pool comparison uses a star. Choose USDC as the hub and create a USDC–DAI pool and a USDC–USDT pool. DAI–USDT trades traverse both spokes. This gives every pair a route using two pools rather than requiring a separate pool for every edge of a complete graph.

That topology is central to the comparisons developed in Winning the Liquidity Graph and Capital Efficiency Across a Token Universe. As the token universe grows, shared liquidity can improve Multiswap's relative capital efficiency. We must measure that advantage at the universe level while keeping starting asset inventories matched.

Here, the first historical case uses USDC, DAI and USDT, the assets in Curve's 3pool test case. It is deliberately small enough to make every route and balance allocation explicit.

Starting inventoryMultiswapCurve USDC–DAI spokeCurve USDC–USDT spoke
USDC115,041,657.7757,520,828.8857,520,828.88
DAI57,520,828.8857,520,828.880
USDT57,520,828.88057,520,828.88

Displayed amounts are rounded; initialization uses the same full quantities for both venues. Aggregate TVL is $230 million in each venue at the first historical observation, with identical TVL for each asset. Curve's hub balance is split between its spokes. It is never counted twice.

All initial internal prices equal one. Both venues see the same external prices, which can differ from one. Neither venue receives an independent pre-run rebalance or a preferential starting allocation.

Exactly what happens each hour

The archived CoinGecko series spans July 8, 2026 at 08:00 UTC to September 6, 2026 at 07:00 UTC. Each hourly observation uses the latest available price for each asset, with no look-ahead, interpolation or smoothing. The loader rejects prices two hours old or older.

At each observation, the simulation:

  1. Considers both directions of all three asset pairs against the external market.
  2. Calculates the most profitable finite trade for each direction, measured in USD.
  3. Executes the best profitable trade and updates the venue's reserves.
  4. Repeats up to the stated number of trades for that observation.
  5. Probes every direction with $10,000, $100,000 and $1 million inputs, recording quotes without changing the continuing state.

The following hour begins with the previous hour's final reserves. Pools are never reset to balance within a run.

There are two scheduling runs: at most one arbitrage trade per observation, and at most four, applied identically to both venues. The four-trade run recomputes the best opportunity after each execution. Neither schedule claims to fully equilibrate the market.

Hub trades are USDC ↔ DAI and USDC ↔ USDT. Non-hub trades are DAI ↔ USDT. Multiswap executes each pair directly; Curve routes the latter through USDC. This experiment uses pairwise orders for both venues and makes no claim about native basket-order performance.

What is reused from Curvesim

The Curve spokes call Curvesim 0.5.0's actual integer CurvePool.exchange implementation. A separate integration baseline replays its unchanged SimpleArbitrageur against a three-coin SimCurvePool. That baseline is not the comparator in the charts.

The comparative trader subclasses Curvesim's Trader and follows its documented simple-strategy structure. One sizing change is necessary: Curvesim normally solves for a terminal marginal price equal to market. For a finite post-trade-priced Multiswap quote, that is not the condition maximizing the trade's profit. The adapter therefore optimizes actual output value minus input value for both venues. On a two-coin Curve validation case, it agrees with the original sizing method's achieved profit within 10910^{-9}.

The historical window is newly retrieved from Curvesim's supported CoinGecko provider. It is not a reproduction of an upstream test's original dates or stored results.

Fixed parameters and explicit omissions

Curve uses constructor A=200 on each two-coin spoke. This corresponds to A=100A=100 in the original two-coin whitepaper convention. Configured A remains fixed; the exact invariant still captures changing curvature as balances change. No administrative A ramp is supplied.

Multiswap sweeps es=0.98,0.99,0.995,0.997,0.999e_s=0.98, 0.99, 0.995, 0.997, 0.999. Fees, gas, Surplus support, deposits and withdrawals are zero or absent. The external market is assumed to execute at the observed price without slippage.

Curvesim's default volume-limited pipeline has not yet been implemented here. It requires a calibrated pool-volume/market-volume multiplier. CoinGecko's rolling volumes are archived but are not substituted for independently available hourly order flow. This matters particularly at the large recorded price excursion.

How often does Multiswap give the better quote?

The first chart compares net output amounts in the four-trade schedule. A win means Multiswap returns more of the requested asset than the Curve route from their respective evolved states. Directions and timestamps receive equal weight; these are quote comparisons, not predicted market shares.

Multiswap quote win rates against the Curve star by input size, with separate hub and non-hub panels.

Open chart at full size

At $1 million input:

Scale elasticityHub quote winsNon-hub quote wins
0.9950.16%52.01%
0.99779.39%99.93%
0.99999.91%99.90%

For this asset universe and Curve configuration, es=0.995e_s=0.995 does not deliver competitive large hub execution. Moving to 0.9970.997 changes that result substantially. The non-hub result reflects the complete routed execution: both Curve spokes participate and both begin with their assigned share of the common capital budget.

The smaller trade sizes matter too. Winning nearly all million-dollar quotes does not imply winning nearly all ten-thousand-dollar quotes. The chart exposes that distinction rather than collapsing it into a single score.

What happens to the reserves?

Quote quality is only one side of market making. A setting must also leave the market able to supply assets after prices move.

Historical market prices, pool value relative to holding, and the smallest reserve fraction throughout the four-trade simulation.

Open chart at full size

The first panel shows the actual archived observations. The second marks each venue's assets at those prices and subtracts the value of holding its initial quantities. The third tracks the smallest remaining reserve as a fraction of that asset's initial amount; it is not a portfolio weight.

On July 9 at 10:00 UTC, the feed records DAI at $1.01103997, USDC at $0.99977115 and USDT at $0.99929485. Arbitrage buys DAI from the pools. In the four-trade run:

VenueMinimum DAI reserve / initial DAIWorst value versus holdingFinal value versus holding
Curve star44.95%−$233,031−$16
Multiswap, 0.99516.58%−$266,146+$191,820
Multiswap, 0.9974.60%−$356,217+$217,030
Multiswap, 0.9990.18%−$521,189+$226,356

The 0.9970.997 setting buys strong execution at the cost of allowing about 95.4% of the initial DAI reserve to leave during this event. At 0.9990.999, almost all of it leaves. These are observed paths in the specified simulation, not claims that either setting is mathematically unsafe.

The DAI spike has been retained exactly as supplied by the provider. It has not been independently verified as executable at that price and the modeled sizes. A historical observation does not establish unlimited external liquidity. Consequently, this excursion is informative about the model's response, but insufficient by itself to calibrate a live market.

The positive final Multiswap values arise from finite, path-dependent execution in this model. They are not fee revenue: exchange fees are zero. Final recovery also does not erase the period when little DAI remained available. Value versus holding and cumulative arbitrage profit are different metrics when external prices change.

Trade frequency materially changes the answer

Sensitivity of minimum reserve levels and final value versus holding to one or four arbitrage trades per hour.

Open chart at full size

For es=0.997e_s=0.997, allowing one trade per observation leaves at least 30.03% of every initial reserve and ends $135,435 above holding. Allowing four reduces the minimum to 4.60% and ends $217,030 above holding.

Both runs use the same history and initial capital. The difference comes from the finite trades executed along the path. This is why an unexplained scheduling or splitting assumption can lead to a misleading elasticity recommendation. Neither schedule is a forecast of actual arbitrage activity.

What this says about setting USD.cav's elasticity

es=0.997e_s=0.997 is a concrete candidate for the next calibration stage, not a production recommendation. In this three-asset experiment, it crosses into strong large-quote competitiveness. Its inventory response also demonstrates that a fixed elasticity cannot be selected from initial depth alone.

The next selection must evaluate quote quality and inventory together under fees, volume-constrained historical trading and credible external execution depth. It must also test Curve A settings, arbitrage splitting, additional historical windows and the intended larger token universe. Multiswap's shared-liquidity advantage should be measured as that universe expands, with the same aggregate starting amount of every asset in the competing star.

USD.cav's connector role can then be tested against a standalone configuration supported by that evidence. The connector should not be the reason for accepting poor standalone execution or an inventory response the market cannot tolerate.

Reproduce every chart and numerical result

All simulation and chart source is preserved on GitHub. Historical data and generated result files are excluded from Git.

With Python 3.12, from that source directory:

python -m venv .venv
.venv/bin/python -m pip install -r requirements.txt
.venv/bin/python init.py
.venv/bin/python experiment.py
.venv/bin/python plot_results.py

Initialization downloads the fixed study window, validates every hourly observation's coverage, and writes the raw responses and their hashes to the local data/ directory. A later initialization verifies and reuses that snapshot; --refresh explicitly fetches it again. Missing history or API access failures stop initialization rather than substituting a different period.

The .gitignore excludes data/, results/, smoke/, .venv/, Python caches and local ZIP archives. The source, dependency versions and published charts remain versioned. Generated CSVs and figures are recreated by the commands above.

The default simulation evaluates both scheduling choices. --limit 12 --steps 1 runs a short validation. Results are reproducible from the same local snapshot. A fresh provider download may differ if historical observations have been revised or the provider's API access policy changes; a fixed date range alone cannot guarantee identical source bytes.

The Multiswap adapter uses double-precision arithmetic. It checks the inverse-price identity with maximum error about 2.54×10142.54\times10^{-14} in the recorded validation and checks value-flow closure within approximately 1.82×1091.82\times10^{-9} in the pool's price unit. It is a mathematical adapter, not a bit-for-bit Solidity emulator.

The full source appears below so the experiment's assumptions can be inspected on this page.

Fixed-window initialization: init.py
"""Download the fixed study window. Data and generated results stay outside Git.

Usage: python init.py [--refresh] [--data-dir PATH]
Existing snapshots are validated and reused unless --refresh is explicit.
API access restrictions or insufficient historical coverage fail loudly.
"""
import argparse
from concurrent.futures import ThreadPoolExecutor
from datetime import datetime, timezone
import hashlib
import json
from pathlib import Path
import urllib.request

ROOT = Path(__file__).resolve().parent
START = '2026-07-08T08:00:00+00:00'
END = '2026-09-06T07:00:00+00:00'
COINS = {'USDC':'usd-coin', 'DAI':'dai', 'USDT':'tether'}

def validate(content, symbol):
data = json.loads(content)
for field in ('prices', 'total_volumes'):
samples = data.get(field)
if not samples:
raise ValueError(f'{symbol}: missing {field}')
times = [row[0] for row in samples]
if times != sorted(set(times)):
raise ValueError(f'{symbol}: timestamps must be unique and increasing')
prices = data['prices']
if any(value <= 0 for _, value in prices):
raise ValueError(f'{symbol}: nonpositive price')
position = 0
start = int(datetime.fromisoformat(START).timestamp()*1000)
end = int(datetime.fromisoformat(END).timestamp()*1000)
for timestamp in range(start, end+1, 3_600_000):
while position+1 < len(prices) and prices[position+1][0] <= timestamp:
position += 1
age = timestamp-prices[position][0]
if not 0 <= age < 7_200_000:
raise ValueError(f'{symbol}: missing or stale hourly coverage at {timestamp}')
return data

def main():
parser=argparse.ArgumentParser(description=__doc__)
parser.add_argument('--refresh',action='store_true')
parser.add_argument('--data-dir',type=Path,default=ROOT/'data')
args=parser.parse_args()
directory=args.data_dir
manifest=directory/'sources.json'
if manifest.exists() and not args.refresh:
sources=json.loads(manifest.read_text())
if {s['symbol'] for s in sources} != set(COINS):
raise ValueError('Snapshot does not contain the required asset set')
for source in sources:
content=(directory/f"{source['symbol']}.json").read_bytes()
if hashlib.sha256(content).hexdigest() != source['sha256']:
raise ValueError(f"Snapshot hash mismatch: {source['symbol']}")
validate(content,source['symbol'])
print('Existing snapshot verified; no data downloaded.')
return
# Buffer supports previous-observation sampling at both endpoints.
start=int(datetime.fromisoformat(START).timestamp())-7200
end=int(datetime.fromisoformat(END).timestamp())+3600
def fetch(item):
symbol,coin=item
url=(f'https://api.coingecko.com/api/v3/coins/{coin}/market_chart/range'
f'?vs_currency=usd&from={start}&to={end}')
request=urllib.request.Request(url,headers={'User-Agent':'curvesim-research'})
content=urllib.request.urlopen(request,timeout=60).read()
data=validate(content,symbol)
source={'symbol':symbol,'url':url,'sha256':hashlib.sha256(content).hexdigest(),
'samples':len(data['prices']),'sample_start':START,'sample_end':END,
'retrieved_at':datetime.now(timezone.utc).isoformat()}
return source,content
with ThreadPoolExecutor(max_workers=3) as executor:
downloaded=list(executor.map(fetch,COINS.items()))
# Write only after all three responses validate. Write manifest last.
directory.mkdir(parents=True,exist_ok=True)
for source,content in downloaded:
(directory/f"{source['symbol']}.json").write_bytes(content)
manifest.write_text(json.dumps([source for source,_ in downloaded],indent=2)+'\n')
print(f'Initialized {START} through {END}: 1,440 hourly observations per asset.')
print('Provider revisions may change results from a newly downloaded snapshot.')

if __name__=='__main__':main()

Simulation and validation: experiment.py
"""Historical Curvesim simple-arbitrage extension. No fees, gas or volume caps.
Run: python experiment.py ; raw CoinGecko responses must already be in data/.
All simulated tokens use 18 decimals, as required by Curvesim's simulation API.
"""
import argparse
from contextlib import contextmanager, ExitStack
import hashlib
from itertools import combinations, permutations
import json
import math
from pathlib import Path
import numpy as np
import pandas as pd
from scipy.optimize import brentq, minimize_scalar
from curvesim.pool import CurvePool
from curvesim.pool.sim_interface import SimCurvePool
from curvesim.templates.trader import Trader, Trade
from curvesim.templates.sim_pool import SimPool
from curvesim.templates import SimAssets
from curvesim.pipelines.simple.trader import SimpleArbitrageur
from init import START, END

ROOT = Path(__file__).resolve().parent
UNIT = 10**18
COINS = ('USDC', 'DAI', 'USDT')
PAIRS = tuple(combinations(range(3), 2))
DIRECTIONS = tuple(permutations(range(3), 2))
A = 200 # Curvesim constructor convention; whitepaper A=100 for two coins.
TVL = 230_000_000
ES = (.98, .99, .995, .997, .999)

class Venue(SimPool):
@property
def assets(self):
return SimAssets(list(COINS), list(COINS), 'simulation')

def get_min_trade_size(self, coin_in):
return 0

def get_max_trade_size(self, coin_in, coin_out, out_balance_perc=.01):
# This method is only for the upstream price-target trader, not ProfitTrader.
upper = self.amounts()[coin_out]
target = upper * (1-out_balance_perc)
while self.quote(coin_in, coin_out, upper) < target:
upper *= 2
return int(brentq(lambda da: self.quote(coin_in, coin_out, da)-target,
0, upper) * UNIT)

def quote(self, i, j, da):
if da <= 0:
return 0.
with self.use_snapshot_context():
return self.trade(i, j, int(da*UNIT))[0] / UNIT

class CurveStar(Venue):
def __init__(self, initial):
assert initial[0] == initial[1] + initial[2]
self.pools = {i: CurvePool(A=A, D=[initial[i], initial[i]], n=2,
fee=0, admin_fee=0) for i in (1, 2)}

def amounts(self):
return np.array([sum(p.balances[0] for p in self.pools.values()),
self.pools[1].balances[1], self.pools[2].balances[1]], dtype=float)/UNIT

def price(self, i, j, use_fee=True):
if i == 0:
return self.pools[j].dydx(0, 1, use_fee=use_fee)
if j == 0:
return self.pools[i].dydx(1, 0, use_fee=use_fee)
return self.price(i, 0, use_fee)*self.price(0, j, use_fee)

def trade(self, i, j, size):
if i == 0:
return self.pools[j].exchange(0, 1, size)
if j == 0:
return self.pools[i].exchange(1, 0, size)
intermediate, fee = self.pools[i].exchange(1, 0, size)
assert fee == 0
return self.pools[j].exchange(0, 1, intermediate)

@contextmanager
def use_snapshot_context(self):
with ExitStack() as stack:
for pool in self.pools.values():
stack.enter_context(pool.use_snapshot_context())
yield

class Multiswap(Venue):
def __init__(self, initial, e_s):
assert 0 <= e_s < 1
self.a = np.array(initial, dtype=float)/UNIT
self.s = self.a.copy() # Initial internal P_i=1, matching both Curve spokes.
self.e_s = e_s

def amounts(self):
return self.a.copy()

def price(self, i, j, use_fee=True):
return (self.s[i]/self.a[i])/(self.s[j]/self.a[j])

def quote(self, i, j, da):
if da <= 0:
return 0.
r = da/self.a[i]
sigma = self.s[i]/self.s[j] * r * math.exp((self.e_s-1)*math.log1p(r))
# Solve in log(a_j/a_j') to remain stable near reserve depletion.
def residual(log_inverse_remaining):
if log_inverse_remaining == 0:
return -math.inf
return (math.log(-math.expm1(-log_inverse_remaining))
+(1-self.e_s)*log_inverse_remaining-math.log(sigma))
upper = 1.
while residual(upper) < 0:
upper *= 2
root = brentq(residual, 0, upper, xtol=1e-15, rtol=1e-14)
return self.a[j]*-math.expm1(-root)

def trade(self, i, j, size):
da_i = size/UNIT
da_j = -self.quote(i, j, da_i)
for k, da in ((i, da_i), (j, da_j)):
r = da/self.a[k]
assert r > -1
self.s[k] *= math.exp(self.e_s*math.log1p(r))
self.a[k] += da
return int(-da_j*UNIT), 0

@contextmanager
def use_snapshot_context(self):
a, s = self.a.copy(), self.s.copy()
try:
yield
finally:
self.a, self.s = a, s

class ProfitTrader(Trader):
"""Curvesim SimpleArbitrageur adaptation: maximize actual USD trade profit.

Retains one best pairwise trade per invocation and Trader execution/logging.
Replaces the terminal-price root with actual finite-output optimization for
BOTH venues. No baskets, forced rebalancing or venue-specific order stream.
"""
def compute_trades(self, market):
best_profit, best_trade = 1e-6, None
for i, j in DIRECTIONS:
if self.pool.price(i, j) <= market[i]/market[j]:
continue
# Beyond this bound, input costs more than the entire receiving
# reserve is worth: no profitable trade is excluded by this bound.
upper = self.pool.amounts()[j]*market[j]/market[i]
def loss(da):
return da*market[i]-self.pool.quote(i, j, da)*market[j]
result = minimize_scalar(loss, bounds=(0, upper), method='bounded',
options={'xatol': .0001})
if not result.success:
raise RuntimeError(result.message)
if -result.fun > best_profit:
best_profit = -result.fun
best_trade = Trade(i, j, int(result.x*UNIT))
return ([best_trade] if best_trade else []), {'expected_profit_usd': best_profit if best_trade else 0.}

def load_data():
manifest = ROOT/'data/sources.json'
if not manifest.exists():
raise RuntimeError('Historical inputs are missing. Run python init.py first.')
sources = json.loads(manifest.read_text())
raw = {}
for source in sources:
content = (ROOT/'data'/f"{source['symbol']}.json").read_bytes()
assert hashlib.sha256(content).hexdigest() == source['sha256']
data = json.loads(content)
series = pd.Series({pd.Timestamp(t, unit='ms', tz='UTC'): p for t,p in data['prices']}).sort_index()
assert series.index.is_unique and (series > 0).all()
raw[source['symbol']] = series
# CoinGecko asynchronous samples: previous observation only, hourly grid.
start = pd.Timestamp(START)
end = pd.Timestamp(END)
index = pd.date_range(start, end, freq='h')
frame = pd.DataFrame({coin: raw[coin].reindex(index, method='ffill') for coin in COINS})
for series in raw.values():
positions = series.index.get_indexer(index, method='pad')
assert (positions >= 0).all(), 'Historical coverage starts too late'
ages = index - series.index[positions]
assert ages.max() < pd.Timedelta('2h'), 'Stale historical prices'
assert not frame.isna().any().any()
return frame

def validation(initial):
curve = CurveStar(initial)
ms = Multiswap(initial, .995)
assert np.array_equal(curve.amounts(), ms.amounts())
for i,j in DIRECTIONS:
assert curve.price(i,j) == ms.price(i,j) == 1
before_a, before_s = ms.a.copy(), ms.s.copy()
ms.trade(1,2,1_000_000*UNIT)
r = (ms.a-before_a)/before_a
P, P_prime = before_s/before_a, ms.s/ms.a
assert np.allclose(P_prime/P, (1+r)**(ms.e_s-1), rtol=1e-13, atol=0)
recovered = np.expm1(np.log(P_prime/P)/(ms.e_s-1))
assert np.allclose(r,recovered,rtol=1e-9,atol=1e-13)
assert abs(np.dot(ms.a-before_a,P_prime)) < 1e-6
assert ms.s.sum() >= before_s.sum()
a = curve.amounts()
quote = curve.quote(1,2,1e6)
assert np.array_equal(a,curve.amounts())
out,_ = curve.trade(1,2,10**6*UNIT)
assert abs(out/UNIT-quote) < 1e-8
assert curve.amounts()[0] == a[0] # routed hub input equals hub output in aggregate
# On an integral Curve curve, original terminal-price and finite-profit
# optimizers agree in profit. Establish correctness before extending trader.
vanilla = SimCurvePool(A=A,D=[initial[1],initial[1]],n=2,fee=0,admin_fee=0)
original = SimpleArbitrageur(vanilla)
trades,_ = original.compute_trades({(0,1):1.001})
t = trades[0]
def profit(da):
with vanilla.use_snapshot_context():
dy,_ = vanilla.trade(t.coin_in,t.coin_out,int(da*UNIT))
target = 1.001 if t.coin_in == 0 else 1/1.001
return dy/UNIT-da*target
optimum = minimize_scalar(lambda da:-profit(da), bounds=(0,float(initial[1])/UNIT), method='bounded')
assert abs(profit(t.amount_in/UNIT)+optimum.fun)<1e-5
return {'initial_amounts':dict(zip(COINS,before_a)),
'initial_price_ratio_all_pairs':1.,'curve_optimizer_profit_difference':abs(profit(t.amount_in/UNIT)+optimum.fun),
'inverted_r_max_error':float(np.max(np.abs(r-recovered))),
'value_flow_closure_error':float(abs(np.dot(ms.a-before_a,P_prime)))}

def run_baseline(frame, initial):
# Original Curvesim simple trader, unchanged. Three-coin pool is a baseline
# execution check ONLY, not the topology used for comparative conclusions.
pool = SimCurvePool(A=A,D=initial,n=3,fee=0,admin_fee=0)
trader = SimpleArbitrageur(pool)
trades = 0
for _, row in frame.iterrows():
result = trader.process_time_sample({(i,j): row.iloc[i]/row.iloc[j] for i,j in PAIRS})
trades += len(result['trades'])
return {'class':'curvesim.pipelines.simple.trader.SimpleArbitrageur',
'pool':'SimCurvePool n=3', 'A_constructor':A,'trade_count':trades,
'final_amounts':[int(v)/UNIT for v in pool.balances],
'final_value_usd':float(np.dot(np.array(pool.balances,dtype=float)/UNIT,frame.iloc[-1]))}

def run(frame, initial, name, venue, steps):
trader = ProfitTrader(venue)
rows, probes = [], []
cumulative_profit, cumulative_volume, count = 0.,0.,0
a0 = venue.amounts()
for timestamp, row in frame.iterrows():
market = row.to_numpy()
for _ in range(steps):
result = trader.process_time_sample(market)
if not result['trades']:
break
for trade in result['trades']:
profit = (trade.amount_out*market[trade.coin_out]-trade.amount_in*market[trade.coin_in])/UNIT
assert profit >= -1e-5
cumulative_profit += profit
cumulative_volume += trade.amount_in/UNIT*market[trade.coin_in]
count += 1
amounts = venue.amounts()
value, hold = float(amounts@market), float(a0@market)
rows.append({'venue':name,'steps':steps,'timestamp':timestamp,'value_usd':value,
'vs_hold_usd':value-hold,'arb_profit_usd':cumulative_profit,
'arb_input_volume_usd':cumulative_volume,'trade_count':count,
**{f'a_{coin}':amounts[i] for i,coin in enumerate(COINS)},
**{f'weight_{coin}':amounts[i]*market[i]/value for i,coin in enumerate(COINS)}})
for i,j in DIRECTIONS:
for usd in (10_000,100_000,1_000_000):
out = venue.quote(i,j,usd/market[i])*market[j]
probes.append({'venue':name,'steps':steps,'timestamp':timestamp,'pair':f'{COINS[i]}->{COINS[j]}',
'route':'hub' if 0 in (i,j) else 'nonhub','input_usd':usd,
'output_usd':out,'shortfall_bp':(1-out/usd)*10_000})
return pd.DataFrame(rows), pd.DataFrame(probes)

def main():
parser=argparse.ArgumentParser()
parser.add_argument('--limit',type=int)
parser.add_argument('--steps',type=int,nargs='+',default=[1,4])
args=parser.parse_args()
frame=load_data()
if args.limit:
frame=frame.iloc[:args.limit]
leaf = int(TVL/(2*frame.iloc[0,0]+frame.iloc[0,1]+frame.iloc[0,2])*UNIT)
initial=[2*leaf,leaf,leaf]
checks=validation(initial)
baseline=run_baseline(frame,initial)
print('baseline',baseline,flush=True)
all_rows,all_probes=[],[]
for steps in args.steps:
venues=[('Curve star',CurveStar(initial))]+[(f'Multiswap {e_s}',Multiswap(initial,e_s)) for e_s in ES]
for name,venue in venues:
rows,probes=run(frame,initial,name,venue,steps)
all_rows.append(rows);all_probes.append(probes)
print(name,steps,'final vs hold',rows.iloc[-1].vs_hold_usd,flush=True)
rows,probes=pd.concat(all_rows),pd.concat(all_probes)
keys=['steps','timestamp','pair','input_usd']
comparator=probes[probes.venue=='Curve star'][keys+['output_usd']].rename(columns={'output_usd':'curve_output_usd'})
probes=probes.merge(comparator,on=keys,validate='many_to_one')
probes['beats_curve']=probes.output_usd>probes.curve_output_usd+1e-6
summaries=[]
for (steps,name),group in rows.groupby(['steps','venue'],sort=False):
quote_group=probes[(probes.venue==name)&(probes.steps==steps)]
final=group.iloc[-1]
summaries.append({'steps':steps,'venue':name,'final_vs_hold_usd':final.vs_hold_usd,
'worst_vs_hold_usd':group.vs_hold_usd.min(),'arb_profit_usd':final.arb_profit_usd,
'arb_input_volume_usd':final.arb_input_volume_usd,
'min_reserve_fraction':min((group[f'a_{coin}']/(initial[i]/UNIT)).min() for i,coin in enumerate(COINS)),
**{f'{route}_quote_win_pct':100*quote_group[quote_group.route==route].beats_curve.mean() for route in ('hub','nonhub')}})
output=ROOT/('smoke' if args.limit else 'results')
output.mkdir(exist_ok=True)
frame.to_csv(output/'market-prices.csv',index_label='timestamp')
rows.to_csv(output/'states.csv',index=False)
probes.to_csv(output/'quotes.csv',index=False)
pd.DataFrame(summaries).to_csv(output/'summary.csv',index=False)
(output/'validation.json').write_text(json.dumps(checks,indent=2)+'\n')
(output/'baseline.json').write_text(json.dumps(baseline,indent=2)+'\n')
(output/'experiment.json').write_text(json.dumps({'start':str(frame.index[0]),'end':str(frame.index[-1]),'samples':len(frame),
'TVL_usd':TVL,'initial_raw_amounts_18_decimals':initial,'A_constructor':A,'e_s':ES,'fees':0,'gas':0,
'volume_limits':None,'trades_per_hour':args.steps,'source':'CoinGecko market_chart 60 days; source files archived',
'curvesim_version':'0.5.0','upstream_reference_commit':'83631945cbb5f7a9b78df70e400ade91babaa461'},indent=2)+'\n')
print(pd.DataFrame(summaries).to_string(index=False),flush=True)

if __name__=='__main__':main()

Chart generation: plot_results.py
"""Render the archived simulation outputs; no browser required."""
from pathlib import Path
import pandas as pd
import matplotlib
matplotlib.use('Agg')
import matplotlib.pyplot as plt
ROOT=Path(__file__).resolve().parent
prices=pd.read_csv(ROOT/'results/market-prices.csv',parse_dates=['timestamp'])
states=pd.read_csv(ROOT/'results/states.csv',parse_dates=['timestamp'])
plt.rcParams.update({'font.size':10,'axes.spines.top':False,'axes.spines.right':False})
fig,axes=plt.subplots(3,1,figsize=(12,10),layout='constrained',sharex=True)
for coin in ('USDC','DAI','USDT'):
axes[0].plot(prices.timestamp,prices[coin],label=coin,lw=1)
for name in ('Curve star','Multiswap 0.995','Multiswap 0.997','Multiswap 0.999'):
group=states[(states.venue==name)&(states.steps==4)]
axes[1].plot(group.timestamp,group.vs_hold_usd/1000,label=name,lw=1)
initial=states[(states.venue==name)&(states.steps==4)].iloc[0]
# Initial raw inventories are recorded in the manifest, before any trade.
import json
a0=json.loads((ROOT/'results/experiment.json').read_text())['initial_raw_amounts_18_decimals']
remaining=pd.concat([group[f'a_{c}']/(a0[i]/1e18) for i,c in enumerate(('USDC','DAI','USDT'))],axis=1).min(axis=1)
axes[2].plot(group.timestamp,100*remaining,label=name,lw=1)
axes[0].set_title('Historical CoinGecko observations — archived hourly samples')
axes[0].set_ylabel('Market price, USD')
axes[0].legend(ncol=3,frameon=False)
axes[1].set_title('Persistent reserves; four profit-maximizing pair trades per hour at most')
axes[1].set_ylabel('Value minus hold, $ thousands')
axes[1].legend(ncol=2,frameon=False)
axes[2].set_ylabel('Smallest reserve / its initial amount, %')
axes[2].set_xlabel('2026, UTC')
for axis in axes:axis.grid(alpha=.2)
fig.suptitle('$230m starting TVL in each venue; identical per-asset inventories; fees and gas = 0',fontsize=13)
fig.savefig(ROOT/'results/history.png',dpi=160)
fig.savefig(ROOT/'results/history.svg')

quotes=pd.read_csv(ROOT/'results/quotes.csv')
colors={'Multiswap 0.995':'#2563eb','Multiswap 0.997':'#059669','Multiswap 0.999':'#dc2626'}
fig,axes=plt.subplots(1,2,figsize=(11,4.8),layout='constrained',sharey=True)
for axis,route,title in zip(axes,('hub','nonhub'),('USDC ↔ DAI or USDT','DAI ↔ USDT')):
for name,color in colors.items():
g=quotes[(quotes.steps==4)&(quotes.venue==name)&(quotes.route==route)]
rate=g.groupby('input_usd').beats_curve.mean()*100
axis.plot(range(3),rate.to_numpy(),marker='o',color=color,label=name.replace('Multiswap ','e_s = '))
axis.set(xticks=range(3),xticklabels=['$10k','$100k','$1m'],ylim=(0,105),title=title,xlabel='Input value at the observed market price')
axis.grid(alpha=.2)
axes[0].set_ylabel('Quotes returning more than the Curve star, %')
axes[1].legend(frameon=False,loc='lower right')
fig.suptitle('Competitiveness after historical arbitrage — four trades per hour at most')
fig.savefig(ROOT/'results/quote-competitiveness.svg')
fig.savefig(ROOT/'results/quote-competitiveness.png',dpi=150)

summary=pd.read_csv(ROOT/'results/summary.csv')
fig,axes=plt.subplots(1,2,figsize=(11,4.8),layout='constrained')
for steps,style in ((1,'--'),(4,'-')):
g=summary[summary.steps==steps].iloc[1:]
labels=[n.split()[-1] for n in g.venue]
axes[0].plot(range(len(g)),g.min_reserve_fraction*100,marker='o',linestyle=style,label=f'At most {steps} trade'+('s' if steps>1 else '')+'/hour')
axes[1].plot(range(len(g)),g.final_vs_hold_usd/1000,marker='o',linestyle=style)
for axis in axes:
axis.set_xticks(range(len(labels)),labels)
axis.set_xlabel('Scale elasticity e_s')
axis.grid(alpha=.2)
curve_min=summary[(summary.steps==4)&(summary.venue=='Curve star')].iloc[0].min_reserve_fraction*100
axes[0].axhline(curve_min,color='#64748b',lw=1,label='Curve star, four trades/hour')
axes[0].set_ylabel('Smallest reserve / initial amount, %')
axes[0].legend(frameon=False,fontsize=9)
axes[1].set_ylabel('Final value minus holding, $ thousands')
fig.suptitle('Trade scheduling changes inventory exposure and accumulated value')
fig.savefig(ROOT/'results/trade-scheduling.svg')
fig.savefig(ROOT/'results/trade-scheduling.png',dpi=150)

Compatibility entry point: fetch_data.py
"""Compatibility entry point for the fixed-window data initializer."""
from init import main
if __name__ == '__main__':
main()

Pinned dependencies: requirements.txt
aiohappyeyeballs==2.7.1
aiohttp==3.14.3
aiosignal==1.4.0
altair==6.2.2
annotated-types==0.8.0
attrs==26.1.0
bitarray==3.11.0
certifi==2026.7.22
charset-normalizer==3.5.1
ckzg==2.1.8
contourpy==1.3.3
curvesim==0.5.0
cycler==0.12.1
cytoolz==1.1.0
eth-abi==6.0.0
eth-account==0.14.0
eth-hash==0.8.0
eth-keyfile==0.10.0
eth-keys==0.8.0
eth-rlp==3.0.0
eth-typing==6.0.0
eth-utils==6.0.0
fonttools==4.64.0
frozenlist==1.8.0
gmpy2==2.3.1
hexbytes==2.0.0
idna==3.19
jinja2==3.1.6
jsonschema==4.26.0
jsonschema-specifications==2025.9.1
kiwisolver==1.5.1
markupsafe==3.0.3
matplotlib==3.11.1
multidict==6.7.1
narwhals==2.25.0
numpy==2.5.2
packaging==26.3
pandas==3.0.5
parsimonious==0.10.0
pillow==12.3.0
propcache==0.5.2
py-ecc==8.0.0
pycryptodome==3.23.0
pydantic==2.13.5
pydantic-core==2.46.5
pyparsing==3.3.2
python-dateutil==2.9.0.post0
python-dotenv==1.2.3
pyunormalize==17.0.0
referencing==0.37.0
regex==2026.9.3
requests==2.34.2
rlp==5.0.0
rpds-py==2026.6.3
scipy==1.18.1
six==1.17.0
tenacity==9.1.4
toolz==1.1.0
typing-extensions==4.16.0
typing-inspection==0.4.4
urllib3==2.7.0
web3==8.0.0
websockets==17.1
yarl==1.24.5

Local data exclusions: .gitignore
/data/
/results/
/smoke/
/.venv/
/__pycache__/
*.zip

Sources