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:
- gRPC streaming - Real-time updates without polling
- Telegram familiarity - Users already have it installed
- Push notifications - Telegram handles delivery
- No app maintenance - Telegram updates their client
The dual-interface approach serves different needs without compromising either.
Lessons Learned¶
- Separate concerns - Bot logic separate from trading core
- Test command handlers - Telegram bots can be tested
- Rate limit at the core - Not the interface layer
- Confirmation for destructive actions - Prevent accidents
The control interface transforms the bot from a black box into a manageable system.