A practical architecture for reference pricing, multiplier normalization, executable spreads, risk controls, execution, and reconciliation.
Arbitrage bots are often described too simply:
Find two different prices and trade the difference.
That is enough for a diagram.
It is not enough for a production trading system.
A Stock Token arbitrage bot on Robinhood Chain has to deal with several layers at once:
- offchain reference prices
- onchain token prices
- corporate-action multipliers
- trading availability
- DEX liquidity
- gas
- slippage
- stale data
- transaction state
- partial execution
- reconciliation
The interesting engineering problem is not detecting that two prices are different.
The interesting problem is determining whether the difference is actually executable.
Robinhood Chain's Stock Tokens are standard ERC-20 assets, and Robinhood documents onchain Chainlink pricing for them. Robinhood also exposes read-only REST APIs for Stock Token metadata, prices, and corporate actions.
This article shows how I would structure the trading system.
1. The architecture
A clean arbitrage bot should separate market data from decision making and execution.
The high-level flow is:
Stock Token Asset Registry
│
▼
Reference Price
│
▼
Onchain / DEX Market Price
│
▼
Price Normalization
│
▼
Spread Engine
│
▼
Risk Engine
│
▼
Execution Engine
│
▼
Transaction Monitoring
│
▼
Position State
│
▼
Reconciliation
This separation matters.
You do not want your code to look like:
if (priceA < priceB) {
buy();
}
A real system needs to know:
Is the quote fresh?
Is the asset active?
Is trading allowed?
Is the multiplier current?
Is enough liquidity available?
What is the executable price for our actual size?
What are the fees?
What is the gas cost?
What happens if the transaction only partially completes?
What happens if the process crashes?
That is where most of the engineering lives.
2. What makes Stock Token arbitrage different?
Robinhood provides a /rhj/assets endpoint containing Stock Token metadata, including deployment information and the current corporate-action multiplier.
The /rhj/prices/{symbol} endpoint provides bid/ask information for the underlying equity. Importantly, Robinhood documents that these REST prices are not multiplier-adjusted, while the onchain Chainlink price incorporates the multiplier.
That creates a critical normalization problem.
Suppose:
Reference price = $100
Multiplier = 0.25
You cannot blindly compare every raw value coming from every source.
Your pricing layer needs a canonical representation.
I would normalize prices into a structure such as:
interface NormalizedPrice {
symbol: string;
source: "reference" | "onchain" | "dex";
price: number;
timestamp: number;
multiplier: number;
}
The purpose of this layer is simple:
Every downstream component receives prices expressed in the same economic unit.
3. Build an asset registry first
Before thinking about arbitrage, build an asset registry.
The registry should know:
interface StockTokenAsset {
symbol: string;
tokenAddress: string;
chainId: number;
currentMultiplier: number;
status: string;
tradingCapabilities: {
fractionalTradability?: string | null;
allDayTradability?: string | null;
extendedHoursFractionalTradability?: boolean | null;
};
}
Robinhood's current documentation exposes these fields through the Stock Token API. The API is rate-limited, and the price endpoint currently documents a 15-second cache window, so the bot should treat freshness as part of its data model rather than assuming every API response is a live tick.
A basic registry loader can look like:
async function loadAssets(): Promise<StockTokenAsset[]> {
const response = await fetch(
"https://api.robinhood.com/rhj/assets"
);
if (!response.ok) {
throw new Error(`Asset API failed: ${response.status}`);
}
const data = await response.json();
return data.assets.map((asset: any) => {
const deployment = asset.deployments.find(
(d: any) => d.chainId === 4663
);
return {
symbol: asset.tokenSymbol,
tokenAddress: deployment?.contractAddress,
chainId: 4663,
currentMultiplier: Number(asset.currentMultiplier),
status: asset.status,
tradingCapabilities: asset.tradingCapabilities,
};
});
}
This registry becomes the source of truth for the rest of the application.
4. Never compare unnormalized prices
This is one of the easiest mistakes to make.
A naive implementation might do:
const spread =
dexPrice - referencePrice;
That is only valid when both prices represent the same economic unit.
Instead:
function normalizeReferencePrice(
rawPrice: number,
multiplier: number
): number {
return rawPrice * multiplier;
}
Then:
const normalizedReference =
normalizeReferencePrice(
referencePrice,
asset.currentMultiplier
);
const spread =
dexPrice - normalizedReference;
The exact normalization formula belongs in a dedicated pricing module so that the rest of the trading engine does not need to understand corporate-action mechanics.
Robinhood documents that corporate actions such as splits and dividends can change the onchain multiplier, and that currentMultiplier is exposed through the asset API.
5. Reference price is not executable price
This is the most important distinction in the bot.
Imagine:
Reference price: $100.00
DEX quoted price: $102.00
The theoretical spread is:
$2.00
That does not mean there is a $2 arbitrage opportunity.
Suppose execution costs are:
DEX slippage: $0.80
Swap fees: $0.25
Gas: $0.20
Risk buffer: $0.30
Then:
Net edge =
2.00 - 0.80 - 0.25 - 0.20 - 0.30
Net edge = $0.45
Now the trade may not even be attractive enough to execute.
The spread engine should therefore calculate:
interface Opportunity {
symbol: string;
referencePrice: number;
executableBuyPrice: number;
executableSellPrice: number;
grossSpread: number;
fees: number;
gasCost: number;
slippageCost: number;
riskBuffer: number;
netEdge: number;
}
The bot should trade on net executable edge, not visual spread.
6. Quote the actual trade size
This is another common mistake.
Suppose the DEX shows:
$100.00
at the top of the order path.
You want to trade:
$10,000
The entire order probably will not execute at the same effective price.
So your quote engine should accept size:
interface QuoteRequest {
token: string;
amountIn: bigint;
}
Then return:
interface ExecutionQuote {
amountIn: bigint;
amountOut: bigint;
averagePrice: number;
priceImpact: number;
gasEstimate: bigint;
gasCostUsd: number;
}
The strategy should use:
expected executable price
rather than:
displayed spot price
This difference becomes increasingly important as position size grows.
7. Add stale-data protection
A trading strategy can be logically correct and still lose money because its data is old.
Every market-data object should contain a timestamp:
interface MarketPrice {
symbol: string;
price: number;
timestamp: number;
}
Then:
function isFresh(
price: MarketPrice,
maxAgeMs: number
): boolean {
return Date.now() - price.timestamp <= maxAgeMs;
}
The opportunity engine can reject stale inputs:
if (!isFresh(referencePrice, 5_000)) {
return null;
}
The exact threshold depends on the strategy.
The important principle is:
Stale data should become an explicit state, not an invisible failure.
Robinhood's API documentation explicitly describes endpoint caching, which makes this particularly relevant when building an automated system.
8. Trading availability is another risk check
A valid price does not automatically mean the asset should be traded.
The Stock Token API exposes trading-capability information, and the price API also exposes isTradingHalt. Robinhood's documentation recommends checking asset trading capabilities before executing trades.
A pre-trade check can look like:
function canTrade(asset: StockTokenAsset): boolean {
if (asset.status !== "ASSET_STATUS_ACTIVE") {
return false;
}
if (
asset.tradingCapabilities.allDayTradability ===
"untradable"
) {
return false;
}
return true;
}
And separately:
if (market.isTradingHalt) {
return null;
}
This should happen before the strategy generates an execution request.
9. The opportunity engine
Now we can combine the components.
A simplified opportunity detector:
function detectOpportunity(
reference: NormalizedPrice,
market: NormalizedPrice,
costs: {
fees: number;
gas: number;
slippage: number;
riskBuffer: number;
}
): Opportunity | null {
const grossSpread =
Math.abs(reference.price - market.price);
const totalCosts =
costs.fees +
costs.gas +
costs.slippage +
costs.riskBuffer;
const netEdge =
grossSpread - totalCosts;
if (netEdge <= 0) {
return null;
}
return {
symbol: market.symbol,
referencePrice: reference.price,
executableBuyPrice: market.price,
executableSellPrice: reference.price,
grossSpread,
fees: costs.fees,
gasCost: costs.gas,
slippageCost: costs.slippage,
riskBuffer: costs.riskBuffer,
netEdge
};
}
A production implementation should be more sophisticated, but the separation is important.
The strategy should answer:
Is there a trade?
The execution engine should answer:
How do I execute it safely?
10. Risk should sit between strategy and execution
Do not let the strategy call the blockchain directly.
Use a risk layer.
Strategy
│
▼
Risk Engine
│
▼
Execution Engine
The risk engine can enforce:
interface RiskLimits {
maxPositionUsd: number;
maxTradeUsd: number;
maxDailyLossUsd: number;
maxSlippageBps: number;
minNetEdgeBps: number;
}
For example:
function validateTrade(
trade: TradeRequest,
limits: RiskLimits
): boolean {
if (trade.notionalUsd > limits.maxTradeUsd) {
return false;
}
if (trade.slippageBps > limits.maxSlippageBps) {
return false;
}
if (trade.netEdgeBps < limits.minNetEdgeBps) {
return false;
}
return true;
}
This prevents a bug in the opportunity detector from becoming an unlimited trading problem.
11. Execution should be a state machine
One of the biggest differences between a strategy script and a trading system is state.
A trade should have explicit lifecycle states:
DETECTED
│
▼
VALIDATED
│
▼
QUOTE_READY
│
▼
ORDER_SUBMITTED
│
▼
TX_PENDING
│
├──────────────► TX_FAILED
│
▼
TX_CONFIRMED
│
▼
POSITION_UPDATED
│
▼
RECONCILED
For TypeScript:
type TradeState =
| "DETECTED"
| "VALIDATED"
| "QUOTE_READY"
| "ORDER_SUBMITTED"
| "TX_PENDING"
| "TX_CONFIRMED"
| "TX_FAILED"
| "POSITION_UPDATED"
| "RECONCILED";
This is much safer than maintaining something like:
let tradeOpen = true;
because transaction state exists independently from application state.
12. Transaction submission is not confirmation
Consider:
const tx = await router.swap(...);
At that moment, you do not have a confirmed position.
You have a transaction hash.
So the execution engine should store:
interface SubmittedTrade {
id: string;
txHash: string;
symbol: string;
expectedAmount: bigint;
submittedAt: number;
state: TradeState;
}
Then a transaction monitor can update the state after confirmation.
This allows the bot to recover from:
- process restarts
- RPC failures
- delayed confirmation
- reverted transactions
- duplicate execution attempts
13. Reconciliation is essential
The in-memory strategy state should never be treated as the ultimate truth.
After execution, reconcile against actual chain state.
For example:
Bot says:
AAPL token position = 102.4
Chain says:
AAPL token position = 98.7
The system should not simply continue.
It should detect:
STATE_MISMATCH
and transition into reconciliation.
A reconciliation service can compare:
interface LocalPosition {
token: string;
amount: bigint;
}
interface OnchainPosition {
token: string;
amount: bigint;
}
Then:
function reconcile(
local: LocalPosition,
chain: OnchainPosition
): boolean {
return local.amount === chain.amount;
}
In a production implementation, reconciliation would also compare pending transactions, realized fills, wallet balances, and expected versus actual execution amounts.
14. Corporate actions cannot be ignored
One of the more interesting parts of building around Stock Tokens is corporate-action handling.
Robinhood documents a dedicated /rhj/corporate-actions endpoint and exposes currentMultiplier and pending multiplier information through the asset API. The corporate-action endpoint can be used to understand changes such as forward or reverse splits and reconcile corresponding multiplier changes.
That suggests a dedicated service:
Corporate Action API
│
▼
Multiplier Monitor
│
▼
Asset Registry Update
│
▼
Pricing Engine
│
▼
Risk Engine
This is important because a corporate action can invalidate assumptions in a pricing model.
The pricing layer should therefore never hard-code a multiplier.
Bad:
const multiplier = 1;
Better:
const multiplier =
asset.currentMultiplier;
And even better:
const multiplier =
multiplierService.getCurrent(symbol);
15. Do not assume you can mint directly
There is another architectural detail worth understanding.
Robinhood's documentation currently states that only Authorized Participants can subscribe for Stock Tokens directly from Robinhood Assets on the primary market. Developers therefore generally build applications around existing Stock Tokens rather than assuming arbitrary users can mint them directly.
That changes how I would design an arbitrage system.
The bot should not assume:
Buy Stock Token
↓
Redeem directly for underlying stock
↓
Capture spread
Instead, the strategy should first identify an actual executable path through the available onchain markets and liquidity.
That makes the system more general and much less dependent on an assumed mint/redeem workflow.
16. A better opportunity model
For each opportunity, I would record something like:
interface ArbitrageOpportunity {
id: string;
symbol: string;
referencePrice: number;
normalizedReferencePrice: number;
executableEntryPrice: number;
executableExitPrice: number;
sizeUsd: number;
grossEdgeBps: number;
feesUsd: number;
gasUsd: number;
slippageUsd: number;
netEdgeUsd: number;
netEdgeBps: number;
detectedAt: number;
expiresAt: number;
}
This creates an important concept:
An opportunity expires.
A spread that existed 2 seconds ago is not necessarily tradable now.
So the execution engine should validate the opportunity again immediately before submission.
17. Suggested TypeScript project structure
I would keep the codebase modular:
src/
├── assets/
│ ├── assetRegistry.ts
│ └── multiplierService.ts
│
├── market/
│ ├── robinhoodPrice.ts
│ ├── onchainPrice.ts
│ └── dexQuote.ts
│
├── strategy/
│ └── arbitrageDetector.ts
│
├── risk/
│ └── riskEngine.ts
│
├── execution/
│ ├── executor.ts
│ ├── transactionMonitor.ts
│ └── stateMachine.ts
│
├── portfolio/
│ ├── positionTracker.ts
│ └── reconciler.ts
│
├── monitoring/
│ ├── metrics.ts
│ └── alerts.ts
│
└── index.ts
This makes it much easier to test individual components.
For example:
Pricing
↓
Strategy
↓
Risk
↓
Execution
↓
Reconciliation
Each component has a clear responsibility.
18. Paper trading should come first
Before sending real transactions, the bot should support paper execution.
Instead of:
await executor.execute(trade);
use:
if (config.paperTrading) {
return simulator.execute(trade);
}
return executor.execute(trade);
The simulator should calculate:
detected opportunity
↓
expected entry
↓
expected exit
↓
fees
↓
slippage
↓
gas
↓
simulated PnL
This also gives you useful data for determining whether the strategy itself is viable.
19. Observability matters
A trading bot should explain why it traded.
For every decision, log:
Symbol
Reference price
Onchain price
Multiplier
Trade size
Estimated slippage
Gas estimate
Gross edge
Net edge
Risk decision
Execution result
Transaction hash
Final position
For example:
[AAPL]
reference: 101.24
normalized: 101.24
dex executable: 103.01
gross edge: 177 bps
fees: 18 bps
gas: 4 bps
slippage: 31 bps
risk buffer: 20 bps
net edge: 104 bps
risk: PASS
execution: SUBMITTED
tx: 0x...
This is far more useful than:
ARB FOUND!!!
when debugging a production strategy.
20. The final architecture
Putting everything together:
STOCK TOKEN
│
▼
┌─────────────────┐
│ ASSET REGISTRY │
│ symbol │
│ contract │
│ multiplier │
│ tradability │
└────────┬────────┘
│
▼
┌─────────────────┐
│ MARKET DATA │
│ reference price │
│ onchain price │
│ DEX quote │
└────────┬────────┘
│
▼
┌─────────────────┐
│ NORMALIZATION │
│ multiplier │
│ timestamps │
│ validity │
└────────┬────────┘
│
▼
┌─────────────────┐
│ SPREAD ENGINE │
│ gross edge │
│ fees │
│ slippage │
│ gas │
│ net edge │
└────────┬────────┘
│
▼
┌─────────────────┐
│ RISK ENGINE │
│ size limits │
│ exposure │
│ stale data │
│ edge threshold │
└────────┬────────┘
│
▼
┌─────────────────┐
│ EXECUTION │
│ quote │
│ transaction │
│ monitoring │
└────────┬────────┘
│
▼
┌─────────────────┐
│ POSITION │
│ TRACKING │
└────────┬────────┘
│
▼
┌─────────────────┐
│ RECONCILIATION │
│ chain vs local │
└─────────────────┘
This is the architecture I would use for a serious Stock Token arbitrage bot on Robinhood Chain.
The arbitrage formula itself is simple.
The engineering around it is not.
21. What I would build next
A useful implementation roadmap is:
Phase 1 - Market data
Build:
Asset Registry
Price Collector
Multiplier Service
Trading Availability Checker
Phase 2 - Opportunity engine
Add:
Price Normalizer
DEX Quoter
Spread Calculator
Cost Model
Opportunity Expiration
Phase 3 - Risk
Add:
Position Limits
Trade Limits
Slippage Limits
Minimum Edge
Emergency Stop
Phase 4 - Execution
Add:
Transaction Builder
Nonce Management
Transaction Monitor
State Machine
Phase 5 - Recovery
Add:
Position Tracker
Reconciliation
Corporate Action Monitor
Crash Recovery
Alerts
At that point, you no longer have a simple arbitrage script.
You have a trading system.
Conclusion
The main lesson is that a Stock Token arbitrage bot is not primarily a price-comparison program.
It is an execution system.
The most important components are:
Reference Data
↓
Normalization
↓
Executable Pricing
↓
Risk
↓
Execution
↓
Reconciliation
Robinhood Chain provides an EVM-compatible environment, and its Stock Token infrastructure exposes both onchain and offchain data that can be composed into trading applications.
The difference between a demo bot and a production-oriented bot is what happens after the signal.
That is where execution state, risk controls, recovery, and reconciliation become the actual engineering problem.
Build something similar?
I build custom trading automation around Robinhood Chain and Stock Tokens, including:
- Stock Token trading bots
- arbitrage systems
- automated execution
- portfolio and position tracking
- trading terminals
- market monitors
- risk engines
- onchain reconciliation
The architecture above can also be adapted to a specific strategy, token universe, execution venue, or client trading workflow.
Top comments (0)