A useful arbitrage bot is not just:
if (priceA < priceB) {
buy();
}
That logic can detect a price difference.
It cannot tell you whether the trade is actually executable.
A production Stock Token arbitrage bot on Robinhood Chain needs to solve several problems at the same time:
- reference pricing
- onchain pricing
- corporate-action multipliers
- liquidity
- trading availability
- fees
- gas
- slippage
- stale data
- transaction state
- partial execution
- reconciliation
The important engineering question is therefore not:
"Is there a spread?"
It is:
"Is there a tradeable spread after every relevant cost and constraint?"
This article shows how I would structure that system in TypeScript.
1. What the arbitrage bot actually does
The basic system is:
Stock Token Asset Data
↓
Reference Price
↓
Onchain / DEX Price
↓
Price Normalization
↓
Spread Calculation
↓
Liquidity Check
↓
Risk Engine
↓
Execution
↓
Transaction Monitoring
↓
Position State
↓
Reconciliation
Each layer has one responsibility.
The arbitrage scanner discovers possible opportunities.
The risk engine decides whether the opportunity is acceptable.
The execution engine turns the opportunity into a transaction.
The reconciliation layer determines what actually happened.
That separation becomes important as soon as the bot starts handling real transactions.
2. Stock Tokens give us several pricing surfaces
Robinhood Chain documentation exposes Stock Token metadata and market information through read-only REST APIs.
The /assets endpoint provides asset metadata, deployments, the current corporate-action multiplier, and trading capabilities.
The /prices/{symbol} endpoint provides bid/ask information.
There is also onchain price information through Chainlink feeds. Robinhood notes that the REST price is the raw underlying-equity bid/ask and is not multiplier-adjusted, while the onchain Chainlink value incorporates the multiplier.
That means a pricing engine should never blindly compare two raw numbers from different sources.
Instead, create a normalized internal representation.
type PriceSource =
| "reference"
| "chainlink"
| "dex";
interface NormalizedPrice {
symbol: string;
source: PriceSource;
priceUsd: number;
timestamp: number;
multiplier: number;
}
Now every downstream component receives a predictable data structure.
3. Start with the Stock Token asset registry
A useful first step is loading /assets.
Conceptually:
interface StockTokenAsset {
id: string;
tokenSymbol: string;
tokenName: string;
currentMultiplier: string;
deployments: Array<{
contractAddress: string;
chainId: number;
}>;
tradingCapabilities?: unknown;
}
The important fields for an arbitrage system are:
tokenSymbol
contractAddress
chainId
currentMultiplier
tradingCapabilities
The contract address matters because a matching ticker or name alone is not sufficient to identify the canonical Stock Token. Robinhood's contract documentation explicitly points developers to the canonical deployed contract addresses.
I would store the registry locally:
PostgreSQL
↓
asset symbol
↓
canonical contract
↓
multiplier
↓
trading capabilities
The bot should not repeatedly discover the same asset metadata during every trading decision.
4. Normalize the multiplier before looking for arbitrage
This is one of the easiest places to build an incorrect arbitrage engine.
Suppose the data layer receives:
Reference price = 100
Multiplier = 0.25
If another pricing source already reflects the multiplier, directly comparing the two values can produce a false spread.
The internal pricing layer should therefore explicitly track whether a source is adjusted.
For example:
interface RawPrice {
symbol: string;
value: number;
multiplierAdjusted: boolean;
timestamp: number;
}
Then normalize:
function normalizePrice(
price: RawPrice,
multiplier: number
): number {
if (price.multiplierAdjusted) {
return price.value;
}
return price.value * multiplier;
}
The exact transformation depends on what economic unit your strategy is using, but the important design principle is:
Never let the spread engine guess how a price was produced.
The pricing adapter should make that explicit.
Robinhood also exposes corporate-action information separately, allowing applications to reconcile multiplier changes with events such as forward or reverse splits.
5. The spread is not the profit
A naive calculation is:
const spread = sellPrice - buyPrice;
A useful arbitrage engine needs to calculate something closer to:
Gross Spread
- DEX fee
- execution slippage
- gas
- protocol costs
- expected execution loss
- safety buffer
= Expected Net Edge
For example:
interface Opportunity {
symbol: string;
buyPrice: number;
sellPrice: number;
quantity: number;
grossSpreadUsd: number;
estimatedFeesUsd: number;
estimatedGasUsd: number;
expectedSlippageUsd: number;
netEdgeUsd: number;
}
Then:
function calculateNetEdge(
grossSpreadUsd: number,
feesUsd: number,
gasUsd: number,
slippageUsd: number
): number {
return (
grossSpreadUsd -
feesUsd -
gasUsd -
slippageUsd
);
}
This changes the bot's decision from:
spread > 0
to:
netEdge > minimumEdge
That is a much more useful trading rule.
6. Liquidity changes the answer
A price difference is meaningless if the available liquidity cannot support the intended trade size.
For a small trade:
quoted price = executable price
For a larger trade:
quoted price ≠ actual execution price
The arbitrage engine should therefore calculate the opportunity for the actual order size.
A simple structure:
interface LiquidityCheck {
requestedSize: bigint;
estimatedOutput: bigint;
priceImpactBps: number;
sufficientLiquidity: boolean;
}
The scanner can then reject opportunities such as:
Spread: 1.10%
Expected slippage: 0.85%
Fees: 0.20%
Gas: 0.15%
--------------------------------
Net edge: -0.10%
A scanner that reports only the first line would incorrectly call this an opportunity.
7. Trading availability needs to be part of the strategy
Another common mistake is treating every Stock Token as continuously tradable in exactly the same way.
Robinhood documents per-asset trading capabilities for market, extended, and overnight sessions. Applications are expected to check those capabilities before execution.
So the arbitrage engine should have a gate:
function canTrade(asset: StockTokenAsset): boolean {
// Evaluate the current session against
// the asset's trading capabilities.
return true;
}
In a production system I would make this more explicit:
ASSET_ACTIVE
↓
SESSION_ALLOWED
↓
TRADE_ALLOWED
↓
OPPORTUNITY_VALID
↓
EXECUTION_ALLOWED
This prevents the bot from treating a data discrepancy as an executable opportunity.
8. Separate scanning from execution
I prefer a strict separation between these components:
Market Data
↓
Opportunity Scanner
↓
Risk Engine
↓
Execution Engine
The scanner should never directly submit transactions.
Instead:
interface TradeIntent {
symbol: string;
side: "BUY" | "SELL";
amount: bigint;
maxSlippageBps: number;
minNetEdgeBps: number;
createdAt: number;
}
The scanner produces the intent.
The risk engine validates it.
Only then does the executor receive it.
9. A simple risk engine
A useful risk engine can enforce:
maximum trade size
maximum position size
maximum daily loss
maximum gas cost
minimum net edge
maximum slippage
stale-data threshold
maximum concurrent trades
Example:
interface RiskLimits {
maxTradeUsd: number;
maxPositionUsd: number;
minNetEdgeBps: number;
maxSlippageBps: number;
maxDataAgeMs: number;
}
And:
function validateOpportunity(
opportunity: Opportunity,
limits: RiskLimits,
now: number,
dataTimestamp: number
): boolean {
const stale = now - dataTimestamp > limits.maxDataAgeMs;
if (stale) return false;
if (opportunity.netEdgeUsd <= 0) return false;
const edgeBps =
(opportunity.netEdgeUsd /
Math.max(opportunity.buyPrice * opportunity.quantity, 1)) *
10_000;
if (edgeBps < limits.minNetEdgeBps) return false;
return true;
}
The important part is that risk is a separate component.
This makes the strategy easier to test and modify.
10. Transaction execution needs states
This is where trading bots often become unreliable.
A transaction is not simply:
submit()
→ success
Real execution can look like:
SIGNAL
↓
RISK_APPROVED
↓
TX_PREPARED
↓
TX_SUBMITTED
↓
PENDING
↓
CONFIRMED
↓
POSITION_UPDATED
But there is another branch:
TX_SUBMITTED
↓
RPC TIMEOUT
↓
UNKNOWN
↓
RECONCILE
↓
CONFIRMED / FAILED / STILL_PENDING
That UNKNOWN state is important.
If an RPC request times out after the transaction was broadcast, submitting another transaction blindly can create a duplicate execution.
A basic state machine might look like:
type TxState =
| "CREATED"
| "SIGNED"
| "SUBMITTED"
| "PENDING"
| "CONFIRMED"
| "FAILED"
| "UNKNOWN";
Then the executor can transition states explicitly.
11. Position tracking should not depend only on local memory
A restart-safe bot needs durable state.
I would store something like:
trade_intent
transaction_hash
symbol
side
requested_amount
filled_amount
average_execution_price
status
created_at
updated_at
The bot can reconstruct the state after a restart:
Database
↓
Open transactions
↓
Chain verification
↓
Actual balances
↓
Actual positions
↓
Recovered state
This is much safer than keeping the position state only inside a running Node.js process.
12. Reconciliation is part of execution
Suppose the bot believes:
BUY 100 units
But the chain shows:
BUY 63 units
The bot's local position must eventually become:
filledAmount = 63
not:
filledAmount = 100
The reconciliation loop can periodically compare:
Local Trade State
│
├── transaction receipt
│
├── wallet balance
│
├── token balance
│
└── onchain events
│
▼
Canonical Position State
That gives the system a recovery path after crashes, delayed RPC responses, missed events, or partial execution.
13. Data freshness matters more than most people expect
Robinhood's Stock Token API is cached and rate-limited. The current documentation states a 60 requests/second limit across the APIs and a 15-second cache for /prices/{symbol}.
That means a bot should not interpret every API response as an instantaneous market tick.
Store the timestamp:
interface Quote {
symbol: string;
bid: number;
ask: number;
generatedAt: number;
}
Then reject stale data:
const age = Date.now() - quote.generatedAt;
if (age > MAX_QUOTE_AGE_MS) {
return;
}
This becomes especially important when the expected arbitrage margin is small.
A stale quote can turn a theoretical edge into a real loss.
14. Project structure
For a TypeScript implementation, I would keep the system modular:
src/
├── assets/
│ ├── assetRegistry.ts
│ └── tradingCapabilities.ts
│
├── market-data/
│ ├── robinhoodApi.ts
│ ├── chainlink.ts
│ └── dex.ts
│
├── pricing/
│ ├── normalization.ts
│ ├── spread.ts
│ └── liquidity.ts
│
├── strategy/
│ └── arbitrage.ts
│
├── risk/
│ └── riskEngine.ts
│
├── execution/
│ ├── executor.ts
│ ├── transactions.ts
│ └── stateMachine.ts
│
├── portfolio/
│ └── positions.ts
│
├── reconciliation/
│ └── reconcile.ts
│
└── index.ts
This lets me test each section independently.
For example:
pricing tests
risk tests
quote tests
execution tests
reconciliation tests
before connecting everything into the live system.
15. Dry-run mode is essential
Before live execution, the bot should support:
DRY_RUN=true
In dry-run mode:
detect opportunity
↓
calculate executable price
↓
calculate expected edge
↓
run risk checks
↓
simulate transaction
↓
record result
But:
do not broadcast
A useful dry-run record might be:
{
"symbol": "AAPL",
"buyPrice": 100.12,
"sellPrice": 101.01,
"grossEdgeBps": 88.9,
"estimatedCostBps": 43.2,
"expectedNetEdgeBps": 45.7,
"approved": true
}
This gives you something measurable before introducing execution risk.
16. Monitoring
Once the bot runs continuously, the most useful metrics are not only P&L.
I would monitor:
opportunities detected
opportunities rejected
risk rejection rate
average spread
average net edge
execution success rate
transaction latency
stale quote count
RPC errors
unknown transactions
reconciliation mismatches
realized P&L
For example:
Detected opportunities 1,284
Risk approved 91
Executed 37
Confirmed 34
Unknown 2
Failed 1
That gives much more insight than simply looking at the wallet balance.
17. Where I would take this next
A basic Stock Token arbitrage bot can start as:
Price Scanner
↓
Spread Calculator
↓
Risk Engine
↓
Execution
A production system becomes:
┌─────────────────────┐
│ Stock Token Data │
└──────────┬──────────┘
↓
┌─────────────────────┐
│ Price Normalization │
└──────────┬──────────┘
↓
┌─────────────────────┐
│ Opportunity Engine │
└──────────┬──────────┘
↓
┌─────────────────────┐
│ Risk Engine │
└──────────┬──────────┘
↓
┌─────────────────────┐
│ Execution Engine │
└──────────┬──────────┘
↓
┌─────────────────────┐
│ Transaction Monitor │
└──────────┬──────────┘
↓
┌─────────────────────┐
│ Position Engine │
└──────────┬──────────┘
↓
┌─────────────────────┐
│ Reconciliation │
└─────────────────────┘
That architecture can then be extended into:
- multi-wallet arbitrage
- automated portfolio rebalancing
- opportunity dashboards
- Telegram or Discord alerts
- execution APIs
- configurable trading strategies
- historical opportunity backtesting
- automated position management
18. My implementation
I built a TypeScript Stock Token arbitrage bot around this architecture for Robinhood Chain.
The implementation focuses on comparing onchain and external pricing sources, normalizing the data, evaluating executable spreads, and keeping execution separate from opportunity detection.
Repository:
[0xhamssog/robinhood-stock-token-arbitrage-bot](https://github.com/0xhamssog/robinhood-stock-token-arbitrage-bot)
The repository is also a useful starting point for extending the system into a larger trading application.
19. Building a custom Stock Token arbitrage bot
The same architecture can be scaled depending on what the project actually needs.
A small build could be:
single strategy
single wallet
basic scanner
basic execution
A medium build could add:
multiple assets
persistent state
risk management
dashboard
alerts
reconciliation
A larger system could include:
multi-wallet execution
multiple strategies
historical data
backtesting
advanced monitoring
API access
portfolio management
custom execution logic
The important part is that the trading logic should be designed around the client's actual execution requirements instead of starting with a generic "bot."
Conclusion
A Stock Token arbitrage bot is easy to describe and much harder to engineer correctly.
The price comparison is only the beginning.
The real system has to answer:
Is the data fresh?
Is the price normalized?
Is the asset tradable?
Is there enough liquidity?
Is the spread still positive after costs?
Does the trade pass risk checks?
What exactly was submitted?
What actually happened onchain?
What is the resulting position?
That is the difference between an arbitrage script and trading infrastructure.
For Robinhood Chain applications, I would treat pricing, risk, execution, state management, and reconciliation as separate engineering problems and connect them through explicit interfaces.
That makes the system easier to test, operate, and extend into a larger Stock Token trading product.
Top comments (0)