DEV Community

Cover image for Building a Stock Token Arbitrage Bot on Robinhood Chain with TypeScript
hamssog
hamssog

Posted on Originally published at hamssog.substack.com

Building a Stock Token Arbitrage Bot on Robinhood Chain with TypeScript

A useful arbitrage bot is not just:

if (priceA < priceB) {
  buy();
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

The important fields for an arbitrage system are:

tokenSymbol
contractAddress
chainId
currentMultiplier
tradingCapabilities
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

Then normalize:

function normalizePrice(
  price: RawPrice,
  multiplier: number
): number {
  if (price.multiplierAdjusted) {
    return price.value;
  }

  return price.value * multiplier;
}
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

For example:

interface Opportunity {
  symbol: string;
  buyPrice: number;
  sellPrice: number;
  quantity: number;
  grossSpreadUsd: number;
  estimatedFeesUsd: number;
  estimatedGasUsd: number;
  expectedSlippageUsd: number;
  netEdgeUsd: number;
}
Enter fullscreen mode Exit fullscreen mode

Then:

function calculateNetEdge(
  grossSpreadUsd: number,
  feesUsd: number,
  gasUsd: number,
  slippageUsd: number
): number {
  return (
    grossSpreadUsd -
    feesUsd -
    gasUsd -
    slippageUsd
  );
}
Enter fullscreen mode Exit fullscreen mode

This changes the bot's decision from:

spread > 0
Enter fullscreen mode Exit fullscreen mode

to:

netEdge > minimumEdge
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

For a larger trade:

quoted price ≠ actual execution price
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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%
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

In a production system I would make this more explicit:

ASSET_ACTIVE
      ↓
SESSION_ALLOWED
      ↓
TRADE_ALLOWED
      ↓
OPPORTUNITY_VALID
      ↓
EXECUTION_ALLOWED
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

The scanner should never directly submit transactions.

Instead:

interface TradeIntent {
  symbol: string;
  side: "BUY" | "SELL";
  amount: bigint;
  maxSlippageBps: number;
  minNetEdgeBps: number;
  createdAt: number;
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Example:

interface RiskLimits {
  maxTradeUsd: number;
  maxPositionUsd: number;
  minNetEdgeBps: number;
  maxSlippageBps: number;
  maxDataAgeMs: number;
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Real execution can look like:

SIGNAL
  ↓
RISK_APPROVED
  ↓
TX_PREPARED
  ↓
TX_SUBMITTED
  ↓
PENDING
  ↓
CONFIRMED
  ↓
POSITION_UPDATED
Enter fullscreen mode Exit fullscreen mode

But there is another branch:

TX_SUBMITTED
      ↓
RPC TIMEOUT
      ↓
UNKNOWN
      ↓
RECONCILE
      ↓
CONFIRMED / FAILED / STILL_PENDING
Enter fullscreen mode Exit fullscreen mode

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";
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

The bot can reconstruct the state after a restart:

Database
   ↓
Open transactions
   ↓
Chain verification
   ↓
Actual balances
   ↓
Actual positions
   ↓
Recovered state
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

But the chain shows:

BUY 63 units
Enter fullscreen mode Exit fullscreen mode

The bot's local position must eventually become:

filledAmount = 63
Enter fullscreen mode Exit fullscreen mode

not:

filledAmount = 100
Enter fullscreen mode Exit fullscreen mode

The reconciliation loop can periodically compare:

Local Trade State
        │
        ├── transaction receipt
        │
        ├── wallet balance
        │
        ├── token balance
        │
        └── onchain events
        │
        ▼
Canonical Position State
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

Then reject stale data:

const age = Date.now() - quote.generatedAt;

if (age > MAX_QUOTE_AGE_MS) {
  return;
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

This lets me test each section independently.

For example:

pricing tests
risk tests
quote tests
execution tests
reconciliation tests
Enter fullscreen mode Exit fullscreen mode

before connecting everything into the live system.


15. Dry-run mode is essential

Before live execution, the bot should support:

DRY_RUN=true
Enter fullscreen mode Exit fullscreen mode

In dry-run mode:

detect opportunity
        ↓
calculate executable price
        ↓
calculate expected edge
        ↓
run risk checks
        ↓
simulate transaction
        ↓
record result
Enter fullscreen mode Exit fullscreen mode

But:

do not broadcast
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

For example:

Detected opportunities       1,284
Risk approved                   91
Executed                        37
Confirmed                       34
Unknown                          2
Failed                           1
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

A production system becomes:

                ┌─────────────────────┐
                │   Stock Token Data  │
                └──────────┬──────────┘
                           ↓
                ┌─────────────────────┐
                │ Price Normalization │
                └──────────┬──────────┘
                           ↓
                ┌─────────────────────┐
                │ Opportunity Engine  │
                └──────────┬──────────┘
                           ↓
                ┌─────────────────────┐
                │    Risk Engine      │
                └──────────┬──────────┘
                           ↓
                ┌─────────────────────┐
                │ Execution Engine    │
                └──────────┬──────────┘
                           ↓
                ┌─────────────────────┐
                │ Transaction Monitor │
                └──────────┬──────────┘
                           ↓
                ┌─────────────────────┐
                │ Position Engine     │
                └──────────┬──────────┘
                           ↓
                ┌─────────────────────┐
                │ Reconciliation      │
                └─────────────────────┘
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

A medium build could add:

multiple assets
persistent state
risk management
dashboard
alerts
reconciliation
Enter fullscreen mode Exit fullscreen mode

A larger system could include:

multi-wallet execution
multiple strategies
historical data
backtesting
advanced monitoring
API access
portfolio management
custom execution logic
Enter fullscreen mode Exit fullscreen mode

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?
Enter fullscreen mode Exit fullscreen mode

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)