← All weekly notes

14 min read

Building a DEX trading engine to understand execution

How a parental-leave learning project grew from AMM arithmetic into a production trading engine with Go, Solidity, simulation and risk controls.

Go · Solidity · Ethereum · DeFi · Trading systems · AMM

During parental leave in January and February 2025, I wanted a project I could explore in small pockets of time. My first goal was deliberately modest: build a simple trading engine and bot so I could understand what happens between observing a market and settling a transaction on-chain.

I had already built Backrunner as an earlier experiment. That taught me how to decode pending swaps, inspect pools and construct bundles. This time I wanted to separate the reusable machinery from any one strategy. I wanted to understand quoting, state freshness, gas, slippage, simulation, signing, submission and reconciliation as one system.

The learning project kept growing. A quote calculator became a market-state service. One strategy became a strategy interface. A transaction sender became a nonce manager and an execution state machine. Eventually it developed into a production trading engine, although learning remained the reason I began it.

This journal records the mental model and selected educational fragments. The actual engine, operating parameters, deployment details and strategies are private. I do not want to publish a ready-to-run system that can be copied, misconfigured or used against other market participants. Nothing here promises profitable trading.

A bot is not the engine

I started by treating a trading bot and a trading engine as the same thing. Separating them made the design much clearer.

  • A strategy answers: Why should I trade now?
  • The engine answers: Can this trade be priced, validated, executed and accounted for safely?

A strategy might look for a price difference between two pools, rebalance inventory, or execute a time-based order. Each still needs the same underlying services: normalized market state, deterministic quotes, risk limits, transaction construction, simulation, nonce ownership and final reconciliation.

My useful boundary became:

observe → snapshot → quote → propose → risk-check        → simulate → sign → submit → reconcile

Only the first decision is strategy-specific. Everything after that should behave like infrastructure.

Start with the smallest AMM model

For a constant-product pool, reserves x and y are related by:

x × y = k

Ignoring fees for a moment, adding token X moves the pool along that curve and removes some token Y. A larger order moves further along the curve, so its average execution price is worse than the current marginal price.

With a fee expressed in basis points, a practical quote is:

effectiveIn = amountIn × (10,000 - feeBps) amountOut = reserveOut × effectiveIn            ─────────────────────────────            reserveIn × 10,000 + effectiveIn

The values are token base units, not decimal user-facing amounts. Integers matter: contracts round down, and an off-chain quote must reproduce that behavior.

This is a compact Go version of the calculation:

package amm import (	"errors"	"math/big") var scale = big.NewInt(10_000) func AmountOut(	amountIn, reserveIn, reserveOut *big.Int,	feeBps int64,) (*big.Int, error) {	if amountIn.Sign() <= 0 || reserveIn.Sign() <= 0 || reserveOut.Sign() <= 0 {		return nil, errors.New("amount and reserves must be positive")	}	if feeBps < 0 || feeBps >= 10_000 {		return nil, errors.New("invalid fee")	} 	// Never mutate caller-owned big.Int values.	effectiveIn := new(big.Int).Mul(amountIn, big.NewInt(10_000-feeBps))	numerator := new(big.Int).Mul(reserveOut, effectiveIn)	denominator := new(big.Int).Add(		new(big.Int).Mul(reserveIn, scale),		effectiveIn,	) 	return new(big.Int).Div(numerator, denominator), nil}

I avoided float64 throughout the engine. Token quantities can be larger than a float can represent exactly, and a tiny rounding difference can turn a safe minimum output into a reverting transaction. big.Int is less convenient, but explicit ownership and copies make the arithmetic predictable.

The constant-product model is only the first adapter. Concentrated-liquidity pools need tick traversal and liquidity changes; stable-swap pools use a different invariant; weighted pools use weights. The engine should not pretend every venue is Uniswap V2. It should expose a common quoting contract and let each pool adapter implement its real mathematics.

type Pool interface {	ID() string	BlockNumber() uint64	QuoteExactIn(tokenIn Address, amountIn *big.Int) (Quote, error)} type Quote struct {	Venue string	TokenIn Address	TokenOut Address	AmountIn *big.Int	AmountOut *big.Int	GasUnits uint64	Block uint64	BlockHash Hash}

The block number and hash are part of the quote because a price without its state version is incomplete.

Spot price, price impact and slippage are different

These terms are easy to mix together.

  • Spot price is the pool's marginal price before my trade.
  • Price impact is the predictable change caused by the size of my own trade.
  • Slippage is the difference between the quote I accepted and what is available when execution occurs.
  • Slippage tolerance is the bound I put on that uncertainty.

For an exact-input swap, the contract should receive a minimum acceptable output:

minOut = expectedOut × (10,000 - slippageBps) / 10,000
func MinimumOut(expectedOut *big.Int, slippageBps int64) *big.Int {	n := new(big.Int).Mul(expectedOut, big.NewInt(10_000-slippageBps))	return n.Div(n, big.NewInt(10_000))}

A large tolerance is not free reliability. It widens the range of prices the transaction accepts and can make poor execution or adversarial ordering more expensive. My engine therefore treats tolerance as a risk policy, not as a convenient way to stop reverts.

Quotes become stale because reserves change, another transaction lands first, a reorganization replaces the head, or an RPC endpoint lags. Fee-on-transfer and rebasing tokens can also violate assumptions made by a basic ERC-20 adapter. The response is not one magic tolerance: it is fresh state, simulation, deadlines, token policy and an explicit failure budget.

A profitable quote is not a profitable trade

An apparent price difference is only gross value. The engine must subtract every cost in a common unit:

net profit = final value           - initial value           - gas cost           - builder or relay payment           - protocol and flash-liquidity fees           - safety margin

For an EIP-1559 transaction, the effective gas price is bounded by the fee cap:

priority = min(maxPriorityFee, maxFee - baseFee)effectiveGasPrice = baseFee + prioritygasCost = gasUsed × effectiveGasPrice

Gas is paid in the native asset, while profit may be measured in a token or stablecoin. Comparing the raw integers is meaningless. I either convert all costs to a chosen accounting asset with a deliberately conservative price, or calculate the final portfolio value in that asset.

type Economics struct {	Proceeds *big.Int // accounting-asset base units	Capital *big.Int	GasCost *big.Int	RelayPayment *big.Int	LiquidityFees *big.Int	SafetyMargin *big.Int} func (e Economics) Net() *big.Int {	net := new(big.Int).Sub(e.Proceeds, e.Capital)	net.Sub(net, e.GasCost)	net.Sub(net, e.RelayPayment)	net.Sub(net, e.LiquidityFees)	return net.Sub(net, e.SafetyMargin)} func (e Economics) Accept(minProfit *big.Int) bool {	return e.Net().Cmp(minProfit) >= 0}

The safety margin absorbs model error; it does not replace correct accounting. Gas estimates can change when a different branch executes, prices can move between simulation and inclusion, and a private bundle may require a payment that is not visible in the swap quote.

State must describe one coherent world

My earliest version fetched pool data independently. That can construct an impossible opportunity: pool A at block 100, pool B at block 101 and a gas estimate from an even later head.

The fix was a versioned snapshot:

type Snapshot struct {	Number uint64	Hash Hash	Markets map[string]MarketState	BaseFee *big.Int	Observed time.Time}

All reads for one evaluation are pinned to the same block tag where the RPC method permits it. Related calls are batched. A candidate carries the snapshot's block number and hash through quoting and simulation. If the canonical head no longer contains that hash, or if the candidate has aged beyond its policy, it expires rather than being silently repriced midway through execution.

This also gives reorganization handling a clear rule:

  1. Optimistically process a new head.
  2. Remember the parent and block hash used by every candidate.
  3. Detect when the new head does not extend the previous canonical head.
  4. Invalidate derived snapshots and rebuild affected pool state.
  5. Reconcile submitted transactions against the new canonical chain.

An in-memory reserve cache is an optimization, not a source of truth. Periodic contract reads and event-sequence checks help detect missed logs or a bad provider response.

Keep strategies pure

I made strategies consume a snapshot and return candidates without signing or sending anything. That makes the same strategy usable in tests, historical replay and live observation.

type Strategy interface {	Name() string	Evaluate(context.Context, Snapshot) ([]Candidate, error)} type Candidate struct {	ID string	Strategy string	Block uint64	BlockHash Hash	Legs []Leg	ExpectedNet *big.Int	ExpiresAt time.Time} type Leg struct {	Venue string	TokenIn Address	TokenOut Address	AmountIn *big.Int	MinOut *big.Int}

Candidates then pass through shared policy: allowed tokens and venues, maximum notional, inventory limits, expected profit, simulation result, deadline and loss limits. A strategy cannot bypass these checks by talking directly to the signer.

The same engine can support several kinds of learning:

StrategySignalMain additional risk
Cross-DEX arbitrageExecutable prices differ across poolsBoth quotes change before atomic execution
BackrunningA preceding swap creates a temporary state changeExact ordering and bundle inclusion
Inventory rebalancingHoldings move outside target bandsExecution cost can exceed rebalance benefit
Time-based executionA schedule releases a small orderMarket impact and incomplete fills
Liquidation monitoringA protocol position crosses its ruleOracle freshness and fierce competition

I do not combine these into one giant strategy. They share adapters and execution services, while their signals, risk limits and measurements remain independent.

What Backrunner contributed

The earlier Backrunner project was useful because it forced me to think in ordered state transitions rather than static prices.

Suppose a target transaction swaps through pool A. A backrun is evaluated against the state after that transaction, not the state observed before it:

state S0  └─ target transaction → state S1       └─ candidate transaction → state S2

The candidate is valid only if simulating that order produces enough final value after every cost. With private order flow or bundles, ordering and atomic inclusion can be expressed to the submission path; with public submission, those assumptions are much weaker. A transaction seen in a pending pool is not guaranteed to land, to keep its position, or even to remain valid.

That experiment evolved into a general principle: strategy inputs are hypotheses; simulation and settlement decide what actually happened.

Concurrency without losing ownership

Go makes it easy to start goroutines, but an engine needs bounded work and explicit ownership.

I use concurrency where tasks are independent:

  • decode logs from different pools;
  • quote unrelated routes;
  • simulate candidates with a bounded number of workers;
  • poll independent providers with deadlines.

I serialize state where order is part of correctness:

  • one owner allocates nonces for an account;
  • one state machine owns a candidate's transitions;
  • a market update is applied in log order;
  • reconciliation decides whether an attempt is included, expired or replaced.
func runSimulators(	ctx context.Context,	workers int,	jobs <-chan Candidate,	results chan<- Simulation,) {	var wg sync.WaitGroup	for i := 0; i < workers; i++ {		wg.Add(1)		go func() {			defer wg.Done()			for {				select {				case <-ctx.Done():					return				case c, ok := <-jobs:					if !ok {						return					}					results <- simulate(ctx, c)				}			}		}()	}	wg.Wait()}

The channel must be bounded. When simulation is slower than candidate generation, backpressure is healthier than an unbounded queue of opportunities that will be stale before they run. Cancellation must also reach RPC calls, not merely stop the outer goroutine.

Execution is a state machine

A successful RPC response does not mean a trade is complete. Submission is the beginning of reconciliation.

type Status uint8 const (	Observed Status = iota	Quoted	Validated	Simulated	Signed	Submitted	Included	Reverted	Expired)

Every transition is recorded with the candidate ID, snapshot hash, transaction hash, nonce and reason. That makes retries deliberate. Repeating submit blindly can create duplicate attempts or conflicting nonces; repeating observe and quote is harmless because those phases have no external side effects.

The nonce manager is effectively a tiny replicated-state problem between the process, RPC providers and the chain. It must understand pending and confirmed nonces, locally reserved nonces, replacement transactions and restarts. I prefer a single owner per signing account and persistent attempt records over allowing every strategy goroutine to call PendingNonceAt and race.

The Solidity boundary should stay small

The Go service does discovery and computation. Solidity enforces the final atomic invariants. The executor should not become a second strategy engine.

This deliberately incomplete sketch shows the boundary, not a deployable contract:

// Educational sketch: router interfaces and token handling are intentionally omitted.contract AtomicExecutor {	address public immutable operator; 	error Unauthorized();	error Expired();	error ProfitTooLow(); 	modifier onlyOperator() {		if (msg.sender != operator) revert Unauthorized();		_;	} 	constructor(address _operator) {		operator = _operator;	} 	function execute(		bytes[] calldata calls,		address accountingToken,		uint256 minProfit,		uint256 deadline	) external onlyOperator {		if (block.timestamp > deadline) revert Expired(); 		uint256 beforeBalance = balanceOf(accountingToken); 		// Production code must restrict targets, selectors, approvals and value.		for (uint256 i; i < calls.length; ++i) {			executeAllowedCall(calls[i]);		} 		uint256 afterBalance = balanceOf(accountingToken);		if (afterBalance < beforeBalance + minProfit) revert ProfitTooLow();	}}

The complete version needs more than this example shows:

  • allow-listed targets and function selectors;
  • safe token transfers and narrowly scoped approvals;
  • per-leg minimum outputs;
  • deadline and chain checks;
  • reentrancy protection where appropriate;
  • careful native-token wrapping and unwrapping;
  • rescue paths with strict authorization;
  • tests for unusual ERC-20 behavior;
  • no arbitrary user-controlled external calls.

Atomicity is valuable: if the last invariant fails, the transaction reverts rather than leaving half a route completed. But a revert still consumes gas, so an on-chain profit check is the last guardrail, not the first profitability test.

Simulation is necessary, not sufficient

Before signing, the engine simulates the exact calldata, sender, value, gas fields and assumed block context. For an ordered bundle it simulates the ordered transactions, not each transaction against unrelated state.

I record:

  • success or revert data;
  • gas used and per-call traces when available;
  • token balances before and after;
  • the block hash and state override assumptions;
  • the predicted coinbase or builder payment;
  • the time between simulation and submission.

Simulation can still disagree with inclusion. The head may change, another transaction may alter state, the block producer may choose a different order, or provider implementations may differ. A passing result therefore receives a short validity window and conservative economics. The receipt and resulting balances remain the source of truth.

Reliability mattered more than another strategy

Once the arithmetic worked, the difficult work was operational:

Multiple providers. I compare head number and hash, measure lag, impose deadlines and fail closed when providers disagree beyond policy.

Deduplication. The same log or pending transaction can arrive through several subscriptions. Stable event and candidate IDs stop repeated work.

Key isolation. Strategy and quote workers never hold raw keys. A narrow signer boundary validates chain ID, destination, value and policy before signing.

Loss limits. Limits exist per trade, token, strategy, block and day. A kill switch stops new signing while reconciliation continues.

Observability. I measure opportunities observed, rejected by reason, simulation latency, stale-state rate, submissions, inclusion latency, reverts and predicted-versus-realized value. A single “profit” counter hides most failures.

Recovery. On restart, the engine reloads non-terminal attempts and reconciles them before issuing new nonces. In-memory state alone is not enough.

These features do not make a strategy cleverer. They make failure visible and bounded, which is what allowed the project to move from a notebook experiment toward production.

Testing the engine I thought I understood

I ended up with several layers of tests.

  1. Arithmetic unit tests reproduce known pool quotes, fees and integer rounding.
  2. Property tests check invariants: output is non-negative, reserves do not become negative, and larger exact inputs do not produce less output in a fixed snapshot.
  3. Adapter fixtures decode real contract logs and calldata captured at known blocks.
  4. Fork tests execute the Solidity boundary against pinned historical state.
  5. Replay tests feed recorded blocks through strategies and compare deterministic candidates.
  6. Shadow mode observes and simulates live data but never signs.
  7. Small-capital rollout comes only after the engine can explain every rejection and reconcile every attempt.

The most useful assertion compares prediction with reality:

quoted outputsimulated outputsettled output

Large differences point to stale state, a wrong pool model, token behavior, ordering assumptions or accounting bugs. Without all three values, the system can lose money and still appear technically healthy.

What I learned

The AMM formula was the easy beginning. A trading engine is a distributed system whose correctness depends on versioned state, ordered events and irreversible side effects.

The ideas I want to remember are:

  • Keep strategy decisions separate from execution infrastructure.
  • Attach block identity to every snapshot, quote and simulation.
  • Calculate with token base units and integers, never floating point.
  • Treat slippage as risk, not as a retry knob.
  • Compare profit and every cost in the same accounting asset.
  • Parallelize independent analysis, but give nonces and lifecycle transitions one owner.
  • Let Solidity enforce small atomic invariants; keep discovery and policy off-chain.
  • Reconcile against receipts and balances rather than trusting submission responses.
  • Run in replay and shadow modes before risking capital.
  • A strategy can be interesting, but the engine earns trust by failing safely.

The private implementation contains operational details that are deliberately absent here: endpoints, keys, contract addresses, thresholds, routing logic and live strategies. The purpose of this note is to explain what I learned, not to distribute a deployable trading system.

Further reading