Skip to content

Dual-Interface Control with gRPC and Telegram

How we built a trading control plane with gRPC for programmatic access and Telegram for mobile-friendly monitoring.

The Interface Problem

A trading bot needs multiple interaction modes:

Use Case Requirement
Automated systems Low-latency, typed API
Mobile monitoring Quick status checks
Emergency control Stop trading immediately
Configuration Update strategies

No single interface serves all needs well. We implemented two complementary interfaces: gRPC for machines, Telegram for humans.

gRPC Service Layer

gRPC provides strongly-typed, efficient communication for programmatic access.

Service Design

We organized services by domain:

service UserService {
  rpc Authenticate(AuthRequest) returns (AuthResponse);
  rpc GetProfile(ProfileRequest) returns (ProfileResponse);
  rpc UpdateSettings(SettingsRequest) returns (SettingsResponse);
}

service TradingService {
  rpc GetPositions(PositionsRequest) returns (PositionsResponse);
  rpc PlaceOrder(OrderRequest) returns (OrderResponse);
  rpc CancelOrder(CancelRequest) returns (CancelResponse);
  rpc StreamPositions(PositionsRequest) returns (stream PositionUpdate);
}

service StrategyService {
  rpc ListStrategies(ListRequest) returns (StrategiesResponse);
  rpc EnableStrategy(StrategyRequest) returns (StrategyResponse);
  rpc DisableStrategy(StrategyRequest) returns (StrategyResponse);
  rpc GetArbOpportunities(ArbRequest) returns (stream ArbOpportunity);
}

Authentication

JWT-based authentication with tier-aware authorization:

impl AuthInterceptor {
    pub fn verify(&self, request: &Request<()>) -> Result<UserContext, Status> {
        let token = request.metadata()
            .get("authorization")
            .ok_or(Status::unauthenticated("Missing token"))?;

        let claims = self.jwt_manager.verify(token)?;
        let context = self.get_user_context(claims.user_id)?;

        // Check rate limits
        context.check_api_rate().await?;

        Ok(context)
    }
}

Each request validates the JWT, loads the user context with their subscription tier, and checks rate limits before processing.

Streaming

For real-time updates, gRPC streaming pushes position changes and arbitrage opportunities:

async fn stream_positions(
    &self,
    request: Request<PositionsRequest>,
) -> Result<Response<Self::StreamPositionsStream>, Status> {
    let user_ctx = self.auth.verify(&request)?;

    let (tx, rx) = mpsc::channel(32);

    // Subscribe to position updates for this user
    self.position_tracker.subscribe(user_ctx.user_id, tx);

    Ok(Response::new(ReceiverStream::new(rx)))
}

Telegram Bot

Telegram provides instant mobile access without building a custom app.

Command Structure

/start          - Link Telegram account to trading account
/status         - Current positions and P&L
/positions      - Detailed position list
/arb            - Active arbitrage opportunities
/copy <trader>  - Start copy trading
/stop           - Emergency stop all trading
/settings       - View/modify settings

Architecture

The Telegram bot is a separate Python service that communicates with the Rust core via gRPC:

┌─────────────────┐     gRPC      ┌──────────────────┐
│  Telegram Bot   │◄────────────►│   Trading Core   │
│    (Python)     │               │     (Rust)       │
└─────────────────┘               └──────────────────┘
        │ Telegram API
┌─────────────────┐
│   Telegram      │
│   Servers       │
└─────────────────┘

Command Handler Pattern

Commands follow a consistent pattern:

@bot.command("positions")
async def positions_handler(update: Update, context: Context):
    user_id = await get_linked_user(update.effective_user.id)
    if not user_id:
        return await update.message.reply_text("Link account with /start")

    try:
        positions = await grpc_client.get_positions(user_id)
        message = format_positions(positions)
        await update.message.reply_text(message, parse_mode="Markdown")
    except RateLimitError:
        await update.message.reply_text("Rate limited. Try again shortly.")

Security Considerations

Concern Mitigation
Account linking One-time code verification
Command injection Validate all inputs
Rate limiting Applied at gRPC layer
Emergency stop Requires confirmation

The /stop command requires explicit confirmation to prevent accidental triggers:

@bot.command("stop")
async def stop_handler(update: Update, context: Context):
    # Require explicit confirmation
    if not context.args or context.args[0] != "CONFIRM":
        return await update.message.reply_text(
            "This will stop ALL trading.\n"
            "Type /stop CONFIRM to proceed."
        )

    await grpc_client.emergency_stop(user_id)
    await update.message.reply_text("Trading stopped.")

Test Coverage

Component Tests
gRPC services 40
Telegram bot 60
Total 100

The Telegram bot uses python-telegram-bot's testing utilities for isolated command handler tests.

Why Two Interfaces?

A REST API could serve both use cases, but:

  1. gRPC streaming - Real-time updates without polling
  2. Telegram familiarity - Users already have it installed
  3. Push notifications - Telegram handles delivery
  4. No app maintenance - Telegram updates their client

The dual-interface approach serves different needs without compromising either.

Lessons Learned

  1. Separate concerns - Bot logic separate from trading core
  2. Test command handlers - Telegram bots can be tested
  3. Rate limit at the core - Not the interface layer
  4. Confirmation for destructive actions - Prevent accidents

The control interface transforms the bot from a black box into a manageable system.