Skip to main content

Component overview

Opinix Trade is built using a microservices-inspired architecture with specialized components for different responsibilities.

Client (Next.js frontend)

The frontend is a Next.js 14 application using the App Router with TypeScript.

Key features

Server Actions

Direct server-side functions for data fetching without API routes

Real-time UI

Live order book updates via WebSocket connections

Wallet Management

Integrated Cashfree payment gateway for deposits/withdrawals

Portfolio Tracking

Track gains/losses based on position values

Order placement

The client submits orders to the backend via the /api/placeorder route:
// From apps/client/app/api/placeorder/route.ts
const data = {
  event_id: eventId,
  l1_expected_price: price,
  l1_order_quantity: quantity,
  offer_type: side, // "yes" or "no"
  userid: userId
};

await fetch('/order/initiate', {
  method: 'POST',
  body: JSON.stringify(data)
});

Project structure

apps/client/
├── app/
   ├── (lobby)/         # Main app routes
   ├── events/      # Event listing & details
   ├── portfolio/   # User portfolio
   └── wallet/      # Deposit/withdraw
   ├── api/             # API routes
   └── components/      # Shared components
├── actions/             # Server actions
└── components/          # UI components

Backend API (Express)

The backend is an Express.js server that validates requests and enqueues orders for processing.

Server setup

// From apps/server/src/index.ts:1
import express from "express";
import { logger } from "@opinix/logger";
import morgan from "morgan";
import { orderRouter } from "./router/orderRouter";
import { eventRouter } from "./router/eventRouter";

const app = express();
const morganFormat = ":method :url :status :response-time ms";

app.use(express.json());
app.use(morgan(morganFormat, {
  stream: {
    write: (message) => {
      const [method, url, status, responseTime] = message.split(" ");
      const formattedLog = `${method} - ${url} - ${status} - ${responseTime?.trim()}ms`;
      logger.info(formattedLog);
    },
  },
}));

app.use("/events", eventRouter);
app.use("/order", orderRouter);

app.listen(3001, () => {
  logger.info(`SERVER | Listening on port 3001`);
});

Order endpoint

The /order/initiate endpoint validates the order and pushes it to Redis:
// From apps/server/src/controllers/order/index.ts:16
export const placeHandler = AsyncWrapper(
  async (req: Request<{}, {}, TPlaceOrderReq>, res) => {
    const { event_id, l1_expected_price, l1_order_quantity, offer_type, userid } = req.body;

    // TODO: check Authorize the user

    const data = {
      type: uuid4(),
      data: {
        market: event_id,
        price: l1_expected_price,
        type: "CREATE_ORDER",
        quantity: l1_order_quantity,
        side: offer_type,
        userId: userid.toString(),
      },
    };

    await addToOrderQueue(data);
    let response = new SuccessResponse("Order placed successfully", 201);
    return res.status(201).json(response);
  }
);
The API responds immediately after enqueuing the order. The actual order execution happens asynchronously in the worker.

Routes

Places a new order in the queue.Request body:
{
  "event_id": "bitcoin-event",
  "l1_expected_price": 5.5,
  "l1_order_quantity": 10,
  "offer_type": "yes",
  "userid": "user123"
}
Response:
{
  "message": "Order placed successfully",
  "statusCode": 201
}
Returns all active trading events.
Returns details for a specific event including the order book.

Worker (Matching engine)

The worker consumes orders from Redis and executes them using the matching engine.

Engine initialization

// From services/engine/src/trade/Engine.ts:26
constructor() {
  let snapshot = null;
  try {
    if (process.env.WITH_SNAPSHOT) {
      snapshot = fs.readFileSync("./snapshot.json");
    }
  } catch (error) {
    console.log("No snapshot found");
  }
  
  if (snapshot) {
    const parsedSnapShot = JSON.parse(snapshot.toString());
    this.orderbooks = parsedSnapShot.orderbook.map(
      (o: any) =>
        new Orderbook(o.bids, o.asks, o.lastTradeId, o.currentPrice, o.event)
    );
    this.balances = new Map(parsedSnapShot.balance);
  } else {
    const lastTradeId = 1;
    this.orderbooks = [new Orderbook([], [], lastTradeId, 0, EXAMPLE_EVENT)];
    this.setBaseBalances();
  }
  
  setInterval(() => {
    this.saveSnapshot();
  }, 1000 * 3);
}
The engine can restore state from a snapshot file, making it resilient to crashes. Snapshots are saved every 3 seconds.

Order processing

The engine processes different message types:
// From services/engine/src/trade/Engine.ts:59
processOrders({
  message,
  clientId,
}: {
  message: MessageFromApi;
  clientId: string;
}) {
  switch (message.type) {
    case CREATE_ORDER:
      try {
        const { executedQty, fills, orderId } = this.createOrders(
          message.data.market,
          message.data.price,
          message.data.quantity,
          message.data.side,
          message.data.userId
        );
        
        RedisManager.getInstance().sendToApi(clientId, {
          type: "ORDER_PLACED",
          payload: { orderId, executedQty, fills },
        });
      } catch (error) {
        RedisManager.getInstance().sendToApi(clientId, {
          type: "ORDER_CANCELLED",
          payload: { orderId: "", executedQty: 0, remainingQty: 0 },
        });
      }
      break;
      
    case CANCEL_ORDER:
      // Cancel order logic
      break;
      
    case GET_DEPTH:
      // Return order book depth
      break;
      
    case GET_OPEN_ORDERS:
      // Return user's open orders
      break;
  }
}

WebSocket server

The WebSocket server handles real-time communication with clients using the ws library.

Server initialization

// From services/wss/src/index.ts:1
import { WebSocketServer } from "ws";
import { config } from "dotenv";
import { UserManager } from "./classes/UserManager";

config();

const port = process.env.PORT as unknown as number;
const wss = new WebSocketServer({ port: port });

wss.on("listening", () => {
  console.log(`WebSocket server is running on port ws://localhost:${wss.options.port}`);
});

wss.on("connection", (ws) => {
  UserManager.getInstance().addUser(ws);
});

Subscription management

The SubscriptionManager handles user subscriptions efficiently:
// From services/wss/src/classes/SubscriptionManager.ts:20
export class SubscriptionManager {
  private static myInstance: SubscriptionManager;
  private subscriptions: Map<string, string[]> = new Map();
  private reverseSubscriptions: Map<string, string[]> = new Map();
  private redisClient: RedisClientType;

  subscribe(userId: string, subscription: string) {
    if (this.subscriptions.get(userId)?.includes(subscription)) {
      return;
    }
    
    const newSubscription = (this.subscriptions.get(userId) || []).concat(subscription);
    this.subscriptions.set(userId, newSubscription);

    const newRevSubscription = (this.reverseSubscriptions.get(subscription) || []).concat(userId)
    this.reverseSubscriptions.set(subscription, newRevSubscription)

    if (this.reverseSubscriptions.get(subscription)?.length === 1) {
      this.redisClient.subscribe(subscription, this.redisCallbackHandler);
    }
  }

  private redisCallbackHandler = (message: string, channel: string) => {
    const parsedMessage = JSON.parse(message);
    this.reverseSubscriptions.get(channel)?.forEach(s => 
      UserManager.getInstance().getUser(s)?.emitMessage(parsedMessage)
    );
  }
}
The reverse subscriptions map is critical for performance. When a message arrives, we can quickly find all users subscribed to that channel without iterating through all users.

Redis manager

The RedisManager provides a centralized interface for Redis operations:
// From packages/order-queue/src/classes/RedisManager.ts:4
export class RedisManager {
  private client: RedisClientType;
  private static instance: RedisManager;

  constructor() {
    this.client = createClient();
    this.client.connect();
  }

  public static getInstance() {
    if (!this.instance) {
      this.instance = new RedisManager();
    }
    return this.instance;
  }

  public pushMessage(message: DbMessage) {
    this.client.lPush("db_processor", JSON.stringify(message));
  }

  public publishMessage(channel: string, message: WsMessage) {
    this.client.publish(channel, JSON.stringify(message));
  }

  public sendToApi(clientId: string, message: MessageToApi) {
    this.client.publish(clientId, JSON.stringify(message));
  }
}

Shared packages

@opinix/types

Shared TypeScript types used across all services:
  • MessageFromApi - Messages from backend to engine
  • MessageToApi - Messages from engine to backend
  • WsMessage - WebSocket message types
  • Order - Order structure
  • Fill - Trade fill data

@opinix/logger

Centralized logging utility using Winston or similar.

@repo/order-queue

Queue management package that handles:
  • Adding orders to Redis queue
  • Processing orders from queue
  • Redis pub/sub for real-time updates
All packages use TypeScript for type safety and are compiled before being used by apps and services.