Skip to content
ExponaBLOG
ENGINEERING CRAFT · DAY 082 / 100

Lessons from building a trading platform for production

Real-time data and precision under pressure.

Expona Admin9 min read

Lessons from building a trading platform

I've spent the last few years working on production trading systems where milliseconds matter and a single rounding error can cost real money. The lessons I learned building a real-time trading platform taught me more about system design, precision, and user psychology than any textbook could. In this post, I'm sharing what we got right, what we got wrong, and the concrete patterns that made the difference.

Real-time everything — Prices, positions and order status update continuously

In a trading platform, staleness is the enemy. Traders won't tolerate a price quote that's even a second old. We learned this the hard way when our initial architecture used HTTP polling with a 5-second interval. Traders were canceling orders based on prices that had already moved. We switched to WebSockets immediately and never looked back.

The architecture became straightforward: maintain a persistent connection per user session, push price updates from our market data feed, and stream order status changes as they happen in the database. Here's how we structured it with NestJS and TypeScript:

typescript
@WebSocketGateway({ namespace: 'trading' })
export class TradingGateway implements OnGatewayConnection, OnGatewayDisconnect {
  constructor(
    private priceService: PriceService,
    private orderService: OrderService,
  ) {}

  @SubscribeMessage('subscribe-ticker')
  handleSubscribe(
    @MessageBody() data: { symbol: string },
    @ConnectedSocket() client: Socket,
  ) {
    const subscription = this.priceService.subscribeToPrices(data.symbol)
      .subscribe((priceUpdate) => {
        client.emit('price-update', {
          symbol: data.symbol,
          bid: priceUpdate.bid,
          ask: priceUpdate.ask,
          timestamp: priceUpdate.timestamp,
          sequence: priceUpdate.sequence,
        });
      });

    client.data.subscriptions = client.data.subscriptions || [];
    client.data.subscriptions.push(subscription);
  }

  @SubscribeMessage('place-order')
  async handlePlaceOrder(
    @MessageBody() order: OrderRequest,
    @ConnectedSocket() client: Socket,
  ) {
    try {
      const result = await this.orderService.placeOrder(order);
      client.emit('order-placed', result);

      // Broadcast position update to this user
      const positions = await this.orderService.getUserPositions(order.userId);
      client.emit('positions-updated', positions);
    } catch (error) {
      client.emit('order-error', { orderId: order.id, message: error.message });
    }
  }

  handleDisconnect(@ConnectedSocket() client: Socket) {
    if (client.data.subscriptions) {
      client.data.subscriptions.forEach((sub) => sub.unsubscribe());
    }
  }
}

One critical lesson: sequence numbers matter more than timestamps. When you're receiving updates from multiple sources at potentially different times, a sequence number lets you detect gaps and reorder out-of-sequence messages. We tag every price update and order status change with a monotonic sequence. The client can detect if they missed an update and trigger a full resync.

We also introduced a heartbeat mechanism. If a client doesn't receive any update (price or order) for 30 seconds, we send an empty ping. This catches network hiccups early and prevents traders from staring at stale screens thinking their connection is dead.

Precision matters — Rounding rules and decimal handling must match the exchange

This is where I learned respect for domain complexity. Different exchanges have different rounding rules. Some round down (floor), some round half-up (banker's rounding), some round to nearest. A 0.01 discrepancy on a million-dollar position isn't a rounding error—it's a real loss or gain that could violate compliance rules.

We spent three weeks debugging a position reconciliation issue before realizing our backend was rounding to 2 decimals with standard JavaScript arithmetic, but the exchange was using banker's rounding to 8 decimals. The positions matched in UI testing but diverged in production with certain asset pairs.

The fix: never use JavaScript's native number type for financial calculations. We switched to the decimal.js library and established a strict schema for decimal handling:

typescript
import Decimal from 'decimal.js';

// Set global precision and rounding
Decimal.set({ precision: 28, rounding: Decimal.ROUND_HALF_UP });

export interface TradePosition {
  symbol: string;
  quantity: Decimal;      // Always Decimal, never number
  entryPrice: Decimal;
  currentPrice: Decimal;
  pnl: Decimal;
}

export class PositionCalculator {
  calculatePnL(position: TradePosition): Decimal {
    const gain = position.currentPrice.minus(position.entryPrice);
    const pnl = gain.times(position.quantity);
    return pnl.toDecimalPlaces(8, Decimal.ROUND_HALF_UP);
  }

  calculateAveragePrice(fills: Trade[]): Decimal {
    if (fills.length === 0) return new Decimal(0);

    const totalCost = fills.reduce(
      (sum, fill) => sum.plus(fill.price.times(fill.quantity)),
      new Decimal(0),
    );

    const totalQuantity = fills.reduce(
      (sum, fill) => sum.plus(fill.quantity),
      new Decimal(0),
    );

    return totalCost.dividedBy(totalQuantity).toDecimalPlaces(8, Decimal.ROUND_HALF_UP);
  }
}

We also created a reconciliation job that runs every 15 minutes comparing our position book against the exchange's official positions. Any discrepancy triggers an alert and a manual review. This isn't about perfection—it's about catching errors fast.

Store decimal values as strings in PostgreSQL (using the numeric type, not float) and convert to Decimal.js on retrieval. Never convert to number for calculations.

Fast, clear UI — Traders need instant feedback and zero ambiguity

Traders are ruthless about UX. A 500ms delay on an order button is felt immediately. Ambiguous feedback—"Processing" that lasts 8 seconds—creates trust erosion and second-guessing. We rebuilt our React UI with these principles:

Optimistic updates: When a trader clicks "Place Order," the UI immediately shows the order in the list with a "Pending" state. We don't wait for the server. If the server rejects it, we flash an error and remove it. This feels instant.

Clear state transitions: Every order has only 5 possible states: Pending → Accepted → Filled/Partially Filled/Rejected/Cancelled. We color-code them and never show ambiguous states like "unknown."

Real-time position impact: When an order is placed, we immediately update the "Available" balance and "Exposure" metrics in the sidebar. Traders see the impact of their action before it's confirmed by the exchange.

typescript
// React hook for order placement with optimistic updates
export function usePlaceOrder() {
  const [orders, setOrders] = useState([]);
  const [error, setError] = useState(null);
  const socket = useSocket();

  const placeOrder = async (orderData: OrderRequest) => {
    const tempId = `temp-${Date.now()}`;
    const optimisticOrder: Order = {
      id: tempId,
      ...orderData,
      status: 'pending',
      createdAt: new Date(),
    };

    // Update UI immediately
    setOrders((prev) => [optimisticOrder, ...prev]);
    setError(null);

    try {
      // Send to server
      socket.emit('place-order', orderData, (response) => {
        if (response.error) {
          // Remove the optimistic order
          setOrders((prev) => prev.filter((o) => o.id !== tempId));
          setError(response.error);
        } else {
          // Replace temp order with real one
          setOrders((prev) =>
            prev.map((o) => (o.id === tempId ? response.order : o)),
          );
        }
      });
    } catch (err) {
      setOrders((prev) => prev.filter((o) => o.id !== tempId));
      setError(err.message);
    }
  };

  return { orders, placeOrder, error };
}

We also eliminated visual noise. No spinner animations that distract from price updates. No modal dialogs that block the order book. Every critical interaction happens inline with minimal context switching.

Legacy modernization — We introduced React into an AngularJS app screen by screen

The trading platform didn't start with React. It was built in AngularJS in 2014. Rewriting it from scratch would have taken 6 months and pulled us away from fixing bugs and adding features. Instead, we took a pragmatic approach: incremental modernization.

We isolated the AngularJS app into independent screens, each with its own route and controller. Then we wrapped each screen in a React component, loading the old code only when needed. Here's the pattern:

typescript
// Legacy AngularJS app bootstrap
let angularBootstrapped = false;

export function LegacyScreenWrapper({ screenName }: { screenName: string }) {
  const containerRef = useRef(null);

  useEffect(() => {
    if (!angularBootstrapped) {
      // Bootstrap AngularJS on first use
      angular.bootstrap(document.body, ['tradingApp']);
      angularBootstrapped = true;
    }

    // Create a new scope for this screen
    const element = document.createElement('div');
    element.setAttribute('ng-controller', `${screenName}Controller`);
    element.innerHTML = `<div></div>`;

    if (containerRef.current) {
      containerRef.current.appendChild(element);
    }

    return () => {
      if (containerRef.current) {
        containerRef.current.innerHTML = '';
      }
    };
  }, [screenName]);

  return <div>;
}

// New React screens
export function ModernOrderBookScreen() {
  return (
    <div>
      
      
    </div>
  );
}

// Router config
export const routes = [
  { path: '/orders/legacy', element:  },
  { path: '/orders', element:  },
  { path: '/positions/legacy', element:  },
  { path: '/positions', element:  },
];
</div>

Over 8 months, we migrated 12 screens from AngularJS to React, one per sprint. We didn't rewrite business logic—we extracted it into services and shared them. This meant traders got feature parity immediately and we avoided the paralysis of a big rewrite.

The hybrid approach had a real cost: bundle size increased by 200KB and page load time went up 300ms initially. But we optimized by code-splitting each screen as a separate chunk, lazy-loading only when needed. By the end, modern screens loaded 40% faster than their AngularJS counterparts.

Common mistakes

  • Assuming WebSocket is enough: WebSockets can drop silently. Always implement reconnection logic, sequence numbers, and a fallback mechanism.
  • Mixing financial precision with application logic: Don't round in the UI—pass full precision to the backend. Let the backend decide on rounding rules based on exchange requirements.
  • Ignoring order confirmation UX: Traders will double-click the submit button if they don't get instant visual feedback. Disable the button immediately, show the order in the list, and only re-enable on error.
  • Rewriting everything at once: A trading platform is too critical to rewrite in one go. Incremental modernization protects revenue while you improve the codebase.
  • Not syncing timestamps with the server: If client and server clocks drift, your sequence-based reconciliation will fail. Use NTP on the server and send server time with every update.

Key takeaways

  • Real-time systems need WebSockets with sequence numbers, heartbeats, and reconnection logic. HTTP polling is too slow and unreliable.
  • Use a dedicated decimal library (decimal.js) for all financial calculations. Never use native JavaScript numbers for money.
  • Trader UX demands instant feedback—optimistic updates, clear state transitions, and zero ambiguity about what's happening.
  • Precision must match the exchange exactly, including rounding rules and decimal places. Reconciliation jobs catch discrepancies before they become compliance issues.
  • Incremental modernization beats big rewrites. Migrate one screen at a time and keep the platform revenue-generating while you improve it.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles