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
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 │
└─────────────────────┘
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;
};
}
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 ?? {}
};
});
}
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;
}
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)
};
}
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
and the current multiplier is:
0.5
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";
}
Then:
export function normalizePrice(
rawPrice: number,
multiplier: number
): number {
return rawPrice * multiplier;
}
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
);
}
Then:
const fresh = isFresh(
quote.generatedAt,
5_000
);
if (!fresh) {
throw new Error(
`Stale market data: ${symbol}`
);
}
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;
}
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;
}
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;
}
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(...);
}
is convenient.
It is also where systems become difficult to maintain.
Instead:
Strategy
↓
Trading Signal
↓
Risk
↓
Execution
Now you can replace the signal strategy without rewriting the execution engine.
For example:
Trading Engine
│
┌────────────────┼────────────────┐
↓ ↓ ↓
Momentum Arbitrage Rebalancing
│ │ │
└────────────────┼────────────────┘
↓
Risk Engine
↓
Execution Engine
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;
}
A basic sizing function:
export function calculateTradeSize(
equityUsd: number,
allocation: number
): number {
return equityUsd * allocation;
}
Then:
const tradeSize = calculateTradeSize(
10_000,
0.10
);
produces:
$1,000
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
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;
}
That gives the strategy enough information to calculate expected execution cost.
The important variable is:
effective execution price
not merely:
displayed spot price
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;
}
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;
}
The architecture becomes:
Signal
↓
Quote
↓
Risk
↓
Execute
Not:
Signal
↓
Execute
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";
A normal path is:
SIGNAL_DETECTED
↓
VALIDATED
↓
RISK_APPROVED
↓
QUOTE_READY
↓
ORDER_SUBMITTED
↓
TX_PENDING
↓
TX_CONFIRMED
↓
POSITION_UPDATED
↓
RECONCILED
This is much safer than something like:
let positionOpen = true;
because blockchain execution is asynchronous.
14. Transaction submission is not confirmation
This distinction is critical.
Suppose:
const txHash =
await executor.submit(order);
The system should record:
TX_PENDING
not:
POSITION_OPEN
The transaction still needs to be monitored.
export interface SubmittedTransaction {
id: string;
txHash: string;
symbol: string;
side: "BUY" | "SELL";
submittedAt: number;
state: ExecutionState;
}
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";
}
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;
}
A position tracker might expose:
class PositionTracker {
async applyFill(
symbol: string,
quantity: bigint,
price: number
) {
// update durable position state
}
}
The important part is that positions are derived from actual execution results.
16. Reconciliation closes the loop
Imagine the database says:
AAPL = 150 tokens
but the actual wallet contains:
AAPL = 147 tokens
The bot has a state mismatch.
The system should detect it:
Local State
↓
Onchain State
↓
Compare
↓
MATCH ─────→ Continue
│
└──────→ Reconcile
A simple reconciler:
export function positionsMatch(
local: bigint,
onchain: bigint
): boolean {
return local === onchain;
}
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
A multiplier should therefore never be hard-coded.
Bad:
const multiplier = 1;
Better:
const multiplier =
asset.currentMultiplier;
And the pricing service should record the multiplier alongside the price.
18. Canonical contracts matter
Suppose someone passes:
AAPL
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;
}
Then validate:
function isCanonicalToken(
expected: string,
actual: string
): boolean {
return (
expected.toLowerCase() ===
actual.toLowerCase()
);
}
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
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
);
}
Even this small example shows the separation:
Asset
↓
Market Data
↓
Signal
↓
Sizing
↓
Quote
↓
Risk
↓
Execution
21. Paper trading
Before live execution, I would implement a paper-trading adapter.
export interface ExecutionAdapter {
execute(
trade: TradeRequest
): Promise<string>;
}
Then:
class PaperExecutor
implements ExecutionAdapter {
async execute(
trade: TradeRequest
): Promise<string> {
console.log(
"[PAPER]",
trade
);
return "paper-trade";
}
}
And:
class LiveExecutor
implements ExecutionAdapter {
async execute(
trade: TradeRequest
): Promise<string> {
// build transaction
// submit transaction
// return tx hash
return "0x...";
}
}
Now the same strategy can run in:
PAPER
or:
LIVE
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
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
This becomes incredibly useful when debugging production behavior.
23. One important architecture decision
I would avoid building:
Strategy → Wallet
and instead build:
Strategy
↓
Risk
↓
Execution
↓
Transaction Monitor
↓
Position
↓
Reconciliation
Why?
Because the same execution infrastructure can support multiple strategies:
TRADING ENGINE
│
┌────────────┼────────────┐
↓ ↓ ↓
Momentum Arbitrage Rebalancing
│ │ │
└────────────┼────────────┘
↓
RISK ENGINE
↓
EXECUTION ENGINE
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 │
└─────────────────┘
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
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)