DEV Community

Cover image for Building a Stock Token Trading Bot on Robinhood Chain with TypeScript
BornToWin
BornToWin

Posted on Originally published at guskarls.substack.com

Building a Stock Token Trading Bot on Robinhood Chain with TypeScript

A practical implementation architecture for market data, signals, risk management, execution, position tracking, and reconciliation.

A trading bot can look deceptively simple:

Price
  ↓
Signal
  ↓
Trade
Enter fullscreen mode Exit fullscreen mode

That model works for a demo.

A production Stock Token trading bot needs considerably more.

The system has to know:

  • which Stock Token contract is canonical
  • whether the asset is currently tradable
  • whether the market data is fresh
  • how the current multiplier affects pricing
  • what price is actually executable
  • how much capital can be used
  • what happened to the transaction
  • what the real onchain position is after execution

Robinhood Chain's Stock Tokens are standard ERC-20 assets, with per-asset onchain Chainlink prices and APIs for asset metadata, prices, and corporate actions. Robinhood Chain is also EVM-compatible, so familiar Ethereum tooling can be used for integration.

This article shows how I would structure the trading engine in TypeScript.


1. The architecture

The first mistake I would avoid is putting everything into one trading loop.

Instead:

┌─────────────────────┐
│   MARKET DATA       │
└──────────┬──────────┘
           ↓
┌─────────────────────┐
│   SIGNAL ENGINE     │
└──────────┬──────────┘
           ↓
┌─────────────────────┐
│    RISK ENGINE      │
└──────────┬──────────┘
           ↓
┌─────────────────────┐
│ EXECUTION ENGINE    │
└──────────┬──────────┘
           ↓
┌─────────────────────┐
│  POSITION TRACKER   │
└──────────┬──────────┘
           ↓
┌─────────────────────┐
│  RECONCILIATION     │
└─────────────────────┘
Enter fullscreen mode Exit fullscreen mode

The strategy decides what it wants to do.

The execution system decides whether and how that action can safely happen.

That separation is the foundation of the rest of the design.


2. Start with the asset registry

Before generating signals, the bot should know the exact asset it is trading.

Robinhood's /rhj/assets endpoint exposes Stock Token metadata including deployments, current multiplier, pending multiplier, status, and trading capabilities. Robinhood also publishes canonical token contracts, and its documentation warns that a token with a matching ticker but a different address is not a Robinhood Stock Token.

I would represent an asset like this:

export interface StockTokenAsset {
  symbol: string;
  tokenAddress: string;
  chainId: number;

  currentMultiplier: number;
  pendingMultiplier?: number;

  status: string;

  tradingCapabilities: {
    market?: string | null;
    extended?: string | null;
    overnight?: string | null;
  };
}
Enter fullscreen mode Exit fullscreen mode

Then load the registry once:

export 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(
      (item: any) => item.chainId === 4663
    );

    return {
      symbol: asset.tokenSymbol,
      tokenAddress: deployment?.contractAddress,
      chainId: 4663,

      currentMultiplier:
        Number(asset.currentMultiplier),

      pendingMultiplier:
        asset.pendingMultiplier
          ? Number(asset.pendingMultiplier)
          : undefined,

      status: asset.status,

      tradingCapabilities:
        asset.tradingCapabilities ?? {}
    };
  });
}
Enter fullscreen mode Exit fullscreen mode

The important idea is that the rest of the trading system should work from this normalized internal representation.


3. Build a dedicated price service

The next layer is market data.

Robinhood's /rhj/prices/{symbol} endpoint provides bid/ask values for the underlying equity. Robinhood documents that these REST prices are not multiplier-adjusted. The onchain Chainlink value is multiplier-adjusted.

That means price normalization belongs in its own module.

export interface ReferenceQuote {
  symbol: string;
  bid: number;
  ask: number;
  generatedAt: number;
  isTradingHalt: boolean;
}
Enter fullscreen mode Exit fullscreen mode

A simple loader:

export async function getReferenceQuote(
  symbol: string
): Promise<ReferenceQuote> {
  const response = await fetch(
    `https://api.robinhood.com/rhj/prices/${symbol}`
  );

  if (!response.ok) {
    throw new Error(
      `Price API failed: ${response.status}`
    );
  }

  const data = await response.json();
  const quote = data.quotes?.[0];

  if (!quote) {
    throw new Error(
      `No price returned for ${symbol}`
    );
  }

  return {
    symbol,
    bid: Number(quote.bid),
    ask: Number(quote.ask),
    generatedAt:
      new Date(quote.generatedAt).getTime(),
    isTradingHalt:
      Boolean(quote.isTradingHalt)
  };
}
Enter fullscreen mode Exit fullscreen mode

Now the strategy does not need to know anything about HTTP responses.


4. Normalize the price

This is one of the most important pieces.

Suppose the raw reference price is:

$200
Enter fullscreen mode Exit fullscreen mode

and the current multiplier is:

0.5
Enter fullscreen mode Exit fullscreen mode

The trading engine should not pass the raw value directly to a component that expects token-denominated pricing.

Create a normalized model:

export interface NormalizedPrice {
  symbol: string;
  price: number;
  multiplier: number;
  timestamp: number;

  source:
    | "reference"
    | "onchain"
    | "market";
}
Enter fullscreen mode Exit fullscreen mode

Then:

export function normalizePrice(
  rawPrice: number,
  multiplier: number
): number {
  return rawPrice * multiplier;
}
Enter fullscreen mode Exit fullscreen mode

The important architectural rule is:

Normalize once, then make every downstream component use the normalized representation.

That prevents pricing logic from being duplicated throughout the codebase.


5. Handle data freshness explicitly

Robinhood's current Stock Token API documentation says the price endpoint is cached for 15 seconds and the REST endpoints are rate-limited to 60 requests/second.

That means freshness should be part of the data model.

export function isFresh(
  generatedAt: number,
  maxAgeMs: number
): boolean {
  return (
    Date.now() - generatedAt <= maxAgeMs
  );
}
Enter fullscreen mode Exit fullscreen mode

Then:

const fresh = isFresh(
  quote.generatedAt,
  5_000
);

if (!fresh) {
  throw new Error(
    `Stale market data: ${symbol}`
  );
}
Enter fullscreen mode Exit fullscreen mode

The specific threshold depends on the strategy.

The key point is that stale data becomes a known trading condition.


6. Trading availability is another guard

A valid price does not automatically mean the asset should be traded.

The price API exposes isTradingHalt, while asset metadata exposes trading capabilities. Robinhood's documentation recommends checking trading capabilities before executing trades.

Create a small guard:

export function canTrade(
  asset: StockTokenAsset,
  quote: ReferenceQuote
): boolean {
  if (
    asset.status !==
    "ASSET_STATUS_ACTIVE"
  ) {
    return false;
  }

  if (quote.isTradingHalt) {
    return false;
  }

  return true;
}
Enter fullscreen mode Exit fullscreen mode

This gets called before every trade decision.


7. Keep the strategy separate

Now we can build the strategy.

Suppose the initial strategy is a simple momentum signal:

export interface TradingSignal {
  symbol: string;
  side: "BUY" | "SELL";

  strength: number;
  reason: string;

  generatedAt: number;
}
Enter fullscreen mode Exit fullscreen mode

Then:

export function generateSignal(
  currentPrice: number,
  previousPrice: number
): TradingSignal | null {
  if (currentPrice > previousPrice) {
    return {
      symbol: "AAPL",
      side: "BUY",
      strength: 0.72,
      reason: "Positive price momentum",
      generatedAt: Date.now()
    };
  }

  return null;
}
Enter fullscreen mode Exit fullscreen mode

This is intentionally simple.

The important part is that the strategy returns a signal.

It does not execute anything.


8. Why strategy and execution should stay separate

This:

if (signal.side === "BUY") {
  await wallet.sendTransaction(...);
}
Enter fullscreen mode Exit fullscreen mode

is convenient.

It is also where systems become difficult to maintain.

Instead:

Strategy
   ↓
Trading Signal
   ↓
Risk
   ↓
Execution
Enter fullscreen mode Exit fullscreen mode

Now you can replace the signal strategy without rewriting the execution engine.

For example:

                    Trading Engine
                         │
        ┌────────────────┼────────────────┐
        ↓                ↓                ↓
    Momentum        Arbitrage        Rebalancing
        │                │                │
        └────────────────┼────────────────┘
                         ↓
                    Risk Engine
                         ↓
                  Execution Engine
Enter fullscreen mode Exit fullscreen mode

This is much more reusable.


9. Add position sizing

A signal should not automatically determine the trade size.

Position sizing belongs to its own layer.

export interface RiskLimits {
  maxTradeUsd: number;
  maxPositionUsd: number;

  maxSlippageBps: number;
  minSignalStrength: number;
}
Enter fullscreen mode Exit fullscreen mode

A basic sizing function:

export function calculateTradeSize(
  equityUsd: number,
  allocation: number
): number {
  return equityUsd * allocation;
}
Enter fullscreen mode Exit fullscreen mode

Then:

const tradeSize = calculateTradeSize(
  10_000,
  0.10
);
Enter fullscreen mode Exit fullscreen mode

produces:

$1,000
Enter fullscreen mode Exit fullscreen mode

In a real strategy, the calculation can incorporate volatility, current exposure, available liquidity, and other risk constraints.


10. Quote the actual trade size

The displayed market price is not necessarily the price for your entire order.

Suppose:

Displayed price:
$100

Trade size:
$10,000
Enter fullscreen mode Exit fullscreen mode

The executable average price may be worse because of available liquidity and price impact.

Therefore, the execution layer should work with a quote:

export interface ExecutionQuote {
  amountIn: bigint;
  amountOut: bigint;

  averagePrice: number;
  priceImpactBps: number;

  gasEstimate: bigint;
  gasCostUsd: number;
}
Enter fullscreen mode Exit fullscreen mode

That gives the strategy enough information to calculate expected execution cost.

The important variable is:

effective execution price
Enter fullscreen mode Exit fullscreen mode

not merely:

displayed spot price
Enter fullscreen mode Exit fullscreen mode

11. Create a trade request

Now combine signal, sizing, and quote.

export interface TradeRequest {
  symbol: string;
  side: "BUY" | "SELL";

  notionalUsd: number;

  expectedPrice: number;
  slippageBps: number;

  signalStrength: number;
}
Enter fullscreen mode Exit fullscreen mode

At this point, the system has enough information to enter the risk layer.


12. Risk should be a hard gate

The risk engine should be able to reject a perfectly valid strategy signal.

export function validateTrade(
  trade: TradeRequest,
  limits: RiskLimits
): boolean {
  if (
    trade.notionalUsd >
    limits.maxTradeUsd
  ) {
    return false;
  }

  if (
    trade.slippageBps >
    limits.maxSlippageBps
  ) {
    return false;
  }

  if (
    trade.signalStrength <
    limits.minSignalStrength
  ) {
    return false;
  }

  return true;
}
Enter fullscreen mode Exit fullscreen mode

The architecture becomes:

Signal
  ↓
Quote
  ↓
Risk
  ↓
Execute
Enter fullscreen mode Exit fullscreen mode

Not:

Signal
  ↓
Execute
Enter fullscreen mode Exit fullscreen mode

That difference matters once the system manages real capital.


13. Model execution as a state machine

Execution should have explicit states.

export type ExecutionState =
  | "SIGNAL_DETECTED"
  | "VALIDATED"
  | "RISK_APPROVED"
  | "QUOTE_READY"
  | "ORDER_SUBMITTED"
  | "TX_PENDING"
  | "TX_CONFIRMED"
  | "TX_FAILED"
  | "POSITION_UPDATED"
  | "RECONCILED";
Enter fullscreen mode Exit fullscreen mode

A normal path is:

SIGNAL_DETECTED
       ↓
VALIDATED
       ↓
RISK_APPROVED
       ↓
QUOTE_READY
       ↓
ORDER_SUBMITTED
       ↓
TX_PENDING
       ↓
TX_CONFIRMED
       ↓
POSITION_UPDATED
       ↓
RECONCILED
Enter fullscreen mode Exit fullscreen mode

This is much safer than something like:

let positionOpen = true;
Enter fullscreen mode Exit fullscreen mode

because blockchain execution is asynchronous.


14. Transaction submission is not confirmation

This distinction is critical.

Suppose:

const txHash =
  await executor.submit(order);
Enter fullscreen mode Exit fullscreen mode

The system should record:

TX_PENDING
Enter fullscreen mode Exit fullscreen mode

not:

POSITION_OPEN
Enter fullscreen mode Exit fullscreen mode

The transaction still needs to be monitored.

export interface SubmittedTransaction {
  id: string;
  txHash: string;

  symbol: string;
  side: "BUY" | "SELL";

  submittedAt: number;
  state: ExecutionState;
}
Enter fullscreen mode Exit fullscreen mode

Then:

async function monitorTransaction(
  txHash: string
) {
  const receipt =
    await provider.waitForTransaction(
      txHash
    );

  if (!receipt) {
    return "TX_PENDING";
  }

  if (receipt.status === 1) {
    return "TX_CONFIRMED";
  }

  return "TX_FAILED";
}
Enter fullscreen mode Exit fullscreen mode

Now transaction state can independently drive the next state transition.


15. Position tracking comes after confirmation

Once the transaction is confirmed, update the position.

export interface Position {
  symbol: string;
  quantity: bigint;

  averageEntryPrice: number;

  realizedPnl: number;
  unrealizedPnl: number;

  updatedAt: number;
}
Enter fullscreen mode Exit fullscreen mode

A position tracker might expose:

class PositionTracker {
  async applyFill(
    symbol: string,
    quantity: bigint,
    price: number
  ) {
    // update durable position state
  }
}
Enter fullscreen mode Exit fullscreen mode

The important part is that positions are derived from actual execution results.


16. Reconciliation closes the loop

Imagine the database says:

AAPL = 150 tokens
Enter fullscreen mode Exit fullscreen mode

but the actual wallet contains:

AAPL = 147 tokens
Enter fullscreen mode Exit fullscreen mode

The bot has a state mismatch.

The system should detect it:

Local State
     ↓
Onchain State
     ↓
Compare
     ↓
MATCH ─────→ Continue
     │
     └──────→ Reconcile
Enter fullscreen mode Exit fullscreen mode

A simple reconciler:

export function positionsMatch(
  local: bigint,
  onchain: bigint
): boolean {
  return local === onchain;
}
Enter fullscreen mode Exit fullscreen mode

A production reconciler should also inspect pending transactions, fills, wallet balances, and expected state.

The key principle is:

Local state helps the bot operate. Onchain state verifies what actually happened.


17. Corporate actions belong in the data layer

Robinhood documents an onchain multiplier for corporate actions and exposes processed corporate actions through /rhj/corporate-actions. For example, split events can explain a corresponding multiplier update.

That suggests a dedicated service:

Corporate Actions
       ↓
Multiplier Monitor
       ↓
Asset Registry
       ↓
Price Normalizer
Enter fullscreen mode Exit fullscreen mode

A multiplier should therefore never be hard-coded.

Bad:

const multiplier = 1;
Enter fullscreen mode Exit fullscreen mode

Better:

const multiplier =
  asset.currentMultiplier;
Enter fullscreen mode Exit fullscreen mode

And the pricing service should record the multiplier alongside the price.


18. Canonical contracts matter

Suppose someone passes:

AAPL
Enter fullscreen mode Exit fullscreen mode

to your system.

The bot should not assume that any ERC-20 named AAPL is the correct asset.

Use the canonical contract registry.

export interface TokenIdentity {
  symbol: string;
  chainId: number;
  address: string;
}
Enter fullscreen mode Exit fullscreen mode

Then validate:

function isCanonicalToken(
  expected: string,
  actual: string
): boolean {
  return (
    expected.toLowerCase() ===
    actual.toLowerCase()
  );
}
Enter fullscreen mode Exit fullscreen mode

Robinhood's documentation explicitly says the canonical token addresses should be used and that matching names or tickers at different addresses are not Robinhood Stock Tokens.

This is a simple but valuable production safeguard.


19. Suggested TypeScript project structure

I would organize the repository like this:

src/
│
├── assets/
│   ├── assetRegistry.ts
│   └── multiplierService.ts
│
├── market/
│   ├── referencePrice.ts
│   ├── onchainPrice.ts
│   └── quoteService.ts
│
├── strategy/
│   ├── signalEngine.ts
│   └── positionSizing.ts
│
├── risk/
│   └── riskEngine.ts
│
├── execution/
│   ├── executor.ts
│   ├── stateMachine.ts
│   └── transactionMonitor.ts
│
├── portfolio/
│   ├── positionTracker.ts
│   └── pnl.ts
│
├── reconciliation/
│   └── reconciler.ts
│
└── monitoring/
    ├── metrics.ts
    └── alerts.ts
Enter fullscreen mode Exit fullscreen mode

This makes the responsibilities obvious.


20. Main trading loop

Now the pieces can be combined.

A simplified loop might look like:

async function processSymbol(
  symbol: string
) {
  const asset =
    await assetRegistry.get(symbol);

  const quote =
    await marketData.getReferenceQuote(
      symbol
    );

  if (!isFresh(
    quote.generatedAt,
    5_000
  )) {
    return;
  }

  if (!canTrade(asset, quote)) {
    return;
  }

  const signal =
    strategy.generate(
      quote
    );

  if (!signal) {
    return;
  }

  const tradeSize =
    positionSizer.calculate(
      symbol,
      signal
    );

  const executionQuote =
    await quoteService.getQuote(
      symbol,
      tradeSize
    );

  const trade = {
    symbol,
    side: signal.side,
    notionalUsd: tradeSize,
    expectedPrice:
      executionQuote.averagePrice,
    slippageBps:
      executionQuote.priceImpactBps,
    signalStrength:
      signal.strength
  };

  if (!riskEngine.validate(trade)) {
    return;
  }

  await executionEngine.execute(
    trade
  );
}
Enter fullscreen mode Exit fullscreen mode

Even this small example shows the separation:

Asset
 ↓
Market Data
 ↓
Signal
 ↓
Sizing
 ↓
Quote
 ↓
Risk
 ↓
Execution
Enter fullscreen mode Exit fullscreen mode

21. Paper trading

Before live execution, I would implement a paper-trading adapter.

export interface ExecutionAdapter {
  execute(
    trade: TradeRequest
  ): Promise<string>;
}
Enter fullscreen mode Exit fullscreen mode

Then:

class PaperExecutor
  implements ExecutionAdapter {

  async execute(
    trade: TradeRequest
  ): Promise<string> {
    console.log(
      "[PAPER]",
      trade
    );

    return "paper-trade";
  }
}
Enter fullscreen mode Exit fullscreen mode

And:

class LiveExecutor
  implements ExecutionAdapter {

  async execute(
    trade: TradeRequest
  ): Promise<string> {

    // build transaction
    // submit transaction
    // return tx hash

    return "0x...";
  }
}
Enter fullscreen mode Exit fullscreen mode

Now the same strategy can run in:

PAPER
Enter fullscreen mode Exit fullscreen mode

or:

LIVE
Enter fullscreen mode Exit fullscreen mode

without changing the strategy layer.


22. Observability

A useful trading system should explain every decision.

For each trade, record:

Symbol
Signal
Reference Price
Multiplier
Trade Size
Executable Price
Expected Slippage
Risk Decision
Transaction Hash
Execution State
Final Position
Enter fullscreen mode Exit fullscreen mode

For example:

AAPL
Signal: BUY
Reference: 201.20
Multiplier: 1.00

Size: $2,500
Executable: 201.34
Slippage: 11 bps

Risk: PASS
Execution: CONFIRMED
Reconciliation: PASS
Enter fullscreen mode Exit fullscreen mode

This becomes incredibly useful when debugging production behavior.


23. One important architecture decision

I would avoid building:

Strategy → Wallet
Enter fullscreen mode Exit fullscreen mode

and instead build:

Strategy
   ↓
Risk
   ↓
Execution
   ↓
Transaction Monitor
   ↓
Position
   ↓
Reconciliation
Enter fullscreen mode Exit fullscreen mode

Why?

Because the same execution infrastructure can support multiple strategies:

                    TRADING ENGINE
                          │
             ┌────────────┼────────────┐
             ↓            ↓            ↓
         Momentum      Arbitrage    Rebalancing
             │            │            │
             └────────────┼────────────┘
                          ↓
                     RISK ENGINE
                          ↓
                  EXECUTION ENGINE
Enter fullscreen mode Exit fullscreen mode

This is where a collection of scripts starts becoming a reusable trading platform.


24. Final architecture

The complete system becomes:

                 STOCK TOKEN
                      │
                      ▼
             ┌─────────────────┐
             │ ASSET REGISTRY  │
             │ Address         │
             │ Multiplier      │
             │ Status          │
             │ Capabilities    │
             └────────┬────────┘
                      ▼
             ┌─────────────────┐
             │  MARKET DATA    │
             │ Reference Price │
             │ Onchain Price   │
             │ Quotes          │
             └────────┬────────┘
                      ▼
             ┌─────────────────┐
             │ SIGNAL ENGINE   │
             │ Strategy        │
             │ Entry / Exit    │
             └────────┬────────┘
                      ▼
             ┌─────────────────┐
             │    RISK ENGINE  │
             │ Size            │
             │ Exposure        │
             │ Slippage        │
             └────────┬────────┘
                      ▼
             ┌─────────────────┐
             │ EXECUTION ENGINE│
             │ Quote           │
             │ TX Builder      │
             │ Monitoring      │
             └────────┬────────┘
                      ▼
             ┌─────────────────┐
             │ POSITION TRACK  │
             │ Balance         │
             │ P&L             │
             └────────┬────────┘
                      ▼
             ┌─────────────────┐
             │ RECONCILIATION  │
             │ Local vs Chain  │
             └─────────────────┘
Enter fullscreen mode Exit fullscreen mode

That architecture is more important than the specific strategy.

You can change the signal.

The trading infrastructure stays.


Conclusion

A Stock Token trading bot on Robinhood Chain is not just a loop that watches prices and submits transactions.

A production-oriented implementation needs:

Asset Registry
      ↓
Market Data
      ↓
Price Normalization
      ↓
Signal
      ↓
Risk
      ↓
Executable Quote
      ↓
Execution State
      ↓
Position Tracking
      ↓
Reconciliation
Enter fullscreen mode Exit fullscreen mode

Robinhood's current documentation provides the underlying pieces: standard ERC-20 Stock Tokens, canonical contract addresses, onchain Chainlink pricing, asset metadata, price APIs, trading capabilities, and corporate-action data.

The main engineering lesson is simple:

A signal is not a trade.

The signal starts the workflow.

Risk, execution, monitoring, and reconciliation are what turn that workflow into a trading system.


Building a custom Stock Token trading bot?

I build custom trading automation around Robinhood Chain and Stock Tokens, including:

  • automated trading bots
  • arbitrage systems
  • execution engines
  • trading terminals
  • market monitors
  • risk engines
  • portfolio tracking
  • reconciliation systems

The architecture can be adapted to a specific strategy, token universe, execution model, or client workflow.

Top comments (0)