Building an Interactive Trading Client with miniFIX and Tokio
Introduction
Modern trading systems require interactive, user-friendly interfaces that can handle real-time market data and order management. This post explores building an interactive trading client using miniFIX with Tokio for async operations, combined with a command-line interface for trader interaction.
What You’ll Learn
- Creating interactive trading applications with miniFIX
- Integrating async Rust (Tokio) with FIX protocols
- Building user prompts and input validation
- Code generation for trading-specific field types
- Designing modular trading client architecture
The Vision: An Interactive Trading Terminal
Imagine a terminal application where traders can:
- Enter new orders interactively
- Choose from validated options for sides, order types, etc.
- See real-time confirmations
- Manage their trading session
Project Structure
The trading client uses code generation to create type-safe field definitions:
// Generated from FIX specification
#[allow(dead_code)]
#[rustfmt::skip]
mod generated_fix42;
use core::fmt;
use minifix::definitions::fix42;
use minifix::FieldType;
use generated_fix42 as strum_fix42;
use strum::IntoEnumIterator;
Interactive User Prompts
The heart of an interactive trading client is gathering user input safely and intuitively:
use inquire::{Select, Text, CustomType};
#[derive(Debug, Clone)]
enum UserAction {
EnterOrder,
ViewPositions,
CancelOrder,
Quit,
}
fn prompt_user_action() -> anyhow::Result<UserAction> {
let options = vec![
UserAction::EnterOrder,
UserAction::ViewPositions,
UserAction::CancelOrder,
UserAction::Quit,
];
let selection = Select::new("What would you like to do?", options)
.prompt()?;
Ok(selection)
}
Order Entry Flow
Let’s build a comprehensive order entry flow with validation:
fn prompt_symbol() -> anyhow::Result<String> {
Text::new("Symbol (e.g., AAPL, TSLA):")
.with_validator(|input: &str| {
if input.len() >= 1 && input.len() <= 12 && input.chars().all(|c| c.is_ascii_alphanumeric()) {
Ok(())
} else {
Err("Symbol must be 1-12 alphanumeric characters")
}
})
.prompt()
.map_err(Into::into)
}
fn prompt_side() -> anyhow::Result<fix42::Side> {
let options = vec![
("Buy", fix42::Side::Buy),
("Sell", fix42::Side::Sell),
];
let selection = Select::new("Side:", options)
.prompt()?;
Ok(selection.1)
}
fn prompt_order_type() -> anyhow::Result<fix42::OrdType> {
let options = vec![
("Market", fix42::OrdType::Market),
("Limit", fix42::OrdType::Limit),
("Stop", fix42::OrdType::Stop),
("Stop Limit", fix42::OrdType::StopLimit),
];
let selection = Select::new("Order Type:", options)
.prompt()?;
Ok(selection.1)
}
fn prompt_time_in_force() -> anyhow::Result<fix42::TimeInForce> {
let options = vec![
("Day", fix42::TimeInForce::Day),
("Good Till Cancel", fix42::TimeInForce::GoodTillCancel),
("Immediate or Cancel", fix42::TimeInForce::ImmediateOrCancel),
("Fill or Kill", fix42::TimeInForce::FillOrKill),
];
let selection = Select::new("Time in Force:", options)
.prompt()?;
Ok(selection.1)
}
fn prompt_price() -> anyhow::Result<rust_decimal::Decimal> {
CustomType::<rust_decimal::Decimal>::new("Price:")
.with_error_message("Please enter a valid price")
.prompt()
.map_err(Into::into)
}
fn prompt_quantity() -> anyhow::Result<u64> {
CustomType::<u64>::new("Quantity:")
.with_error_message("Please enter a valid quantity")
.prompt()
.map_err(Into::into)
}
Order Management Structure
Define structures to manage orders and trading state:
use uuid::Uuid;
use chrono::{DateTime, Utc};
use rust_decimal::Decimal;
#[derive(Debug, Clone)]
pub struct OrderRequest {
pub client_order_id: String,
pub symbol: String,
pub side: fix42::Side,
pub order_type: fix42::OrdType,
pub time_in_force: fix42::TimeInForce,
pub quantity: u64,
pub price: Option<Decimal>,
pub account: String,
pub sender_comp_id: String,
pub target_comp_id: String,
}
impl OrderRequest {
fn new() -> Self {
Self {
client_order_id: Uuid::new_v4().to_string(),
symbol: String::new(),
side: fix42::Side::Buy,
order_type: fix42::OrdType::Market,
time_in_force: fix42::TimeInForce::Day,
quantity: 0,
price: None,
account: String::new(),
sender_comp_id: String::new(),
target_comp_id: String::new(),
}
}
}
#[derive(Debug)]
pub struct TradingSession {
pub orders: Vec<OrderRequest>,
pub sequence_number: u32,
}
impl TradingSession {
fn new() -> Self {
Self {
orders: Vec::new(),
sequence_number: 1,
}
}
fn next_sequence(&mut self) -> u32 {
let seq = self.sequence_number;
self.sequence_number += 1;
seq
}
}
Complete Order Entry Flow
Here’s how the complete interactive flow works:
async fn handle_enter_order(session: &mut TradingSession) -> anyhow::Result<()> {
println!("π New Order Entry");
println!("==================");
// Gather order details interactively
let symbol = prompt_symbol()?;
let side = prompt_side()?;
let order_type = prompt_order_type()?;
let time_in_force = prompt_time_in_force()?;
let quantity = prompt_quantity()?;
// Price is only needed for limit orders
let price = match order_type {
fix42::OrdType::Limit | fix42::OrdType::StopLimit => {
Some(prompt_price()?)
}
_ => None
};
// System information
let sender_comp_id = Text::new("SenderCompID:").prompt()?;
let target_comp_id = Text::new("TargetCompID:").prompt()?;
let account = Text::new("Account:").prompt()?;
// Create order request
let mut order = OrderRequest::new();
order.symbol = symbol;
order.side = side;
order.order_type = order_type;
order.time_in_force = time_in_force;
order.quantity = quantity;
order.price = price;
order.sender_comp_id = sender_comp_id;
order.target_comp_id = target_comp_id;
order.account = account;
// Display order summary
display_order_summary(&order);
// Confirm order
let confirm = inquire::Confirm::new("Submit this order?")
.with_default(false)
.prompt()?;
if confirm {
// Encode and send order
let fix_message = encode_new_order_single(&order, session.next_sequence())?;
println!("β
Order submitted successfully!");
println!("π€ FIX Message: {}", String::from_utf8_lossy(&fix_message));
// Store in session
session.orders.push(order);
// In a real application, you would send this over a network connection
// send_to_broker(&fix_message).await?;
} else {
println!("β Order cancelled");
}
Ok(())
}
fn display_order_summary(order: &OrderRequest) {
println!("\nπ Order Summary");
println!("================");
println!("Order ID: {}", order.client_order_id);
println!("Symbol: {}", order.symbol);
println!("Side: {:?}", order.side);
println!("Type: {:?}", order.order_type);
println!("Quantity: {}", order.quantity);
if let Some(price) = order.price {
println!("Price: ${}", price);
}
println!("Time in Force: {:?}", order.time_in_force);
println!("Account: {}", order.account);
println!();
}
FIX Message Encoding
Convert the interactive order into a FIX message:
use minifix::prelude::*;
use minifix::tagvalue::Encoder;
fn encode_new_order_single(
order: &OrderRequest,
seq_num: u32
) -> anyhow::Result<Vec<u8>> {
let mut encoder = Encoder::default();
let mut buffer = Vec::new();
let mut msg = encoder.start_message(b"FIX.4.2", &mut buffer, b"D");
// Header
msg.set(fix42::MSG_SEQ_NUM, seq_num);
msg.set(fix42::SENDER_COMP_ID, &order.sender_comp_id);
msg.set(fix42::TARGET_COMP_ID, &order.target_comp_id);
// Timestamp
let timestamp = Utc::now().format("%Y%m%d-%H:%M:%S%.3f").to_string();
msg.set(fix42::SENDING_TIME, timestamp);
// Order details
msg.set(fix42::CL_ORD_ID, &order.client_order_id);
msg.set(fix42::SYMBOL, &order.symbol);
msg.set(fix42::SIDE, order.side);
msg.set(fix42::ORD_TYPE, order.order_type);
msg.set(fix42::TIME_IN_FORCE, order.time_in_force);
msg.set(fix42::ORDER_QTY, order.quantity);
msg.set(fix42::ACCOUNT, &order.account);
// Price (if applicable)
if let Some(price) = order.price {
msg.set(fix42::PRICE, price);
}
// Standard fields
msg.set(fix42::HANDL_INST, fix42::HandlInst::AutomatedExecutionOrderPrivateNoBrokerIntervention);
let (encoded_message, _) = msg.done();
Ok(encoded_message.to_vec())
}
Main Application Loop
The main loop handles user interaction and session management:
#[tokio::main]
async fn main() -> anyhow::Result<()> {
println!("π miniFIX Interactive Trading Client");
println!("=====================================");
let mut session = TradingSession::new();
loop {
match prompt_user_action()? {
UserAction::EnterOrder => {
if let Err(e) = handle_enter_order(&mut session).await {
eprintln!("β Error entering order: {}", e);
}
}
UserAction::ViewPositions => {
display_orders(&session.orders);
}
UserAction::CancelOrder => {
// TODO: Implement order cancellation
println!("π§ Order cancellation not implemented yet");
}
UserAction::Quit => {
println!("π Goodbye!");
break;
}
}
println!(); // Add spacing
}
Ok(())
}
fn display_orders(orders: &[OrderRequest]) {
if orders.is_empty() {
println!("π No orders in this session");
return;
}
println!("π Current Orders");
println!("=================");
for (i, order) in orders.iter().enumerate() {
println!("{}. {} {} {} @ {}",
i + 1,
order.symbol,
match order.side {
fix42::Side::Buy => "BUY",
fix42::Side::Sell => "SELL",
_ => "UNKNOWN"
},
order.quantity,
order.price.map(|p| p.to_string()).unwrap_or_else(|| "MARKET".to_string())
);
}
}
Enhanced Features
Input Validation
fn validate_symbol(symbol: &str) -> Result<(), String> {
if symbol.is_empty() {
return Err("Symbol cannot be empty".to_string());
}
if symbol.len() > 12 {
return Err("Symbol too long (max 12 characters)".to_string());
}
if !symbol.chars().all(|c| c.is_ascii_alphanumeric()) {
return Err("Symbol must contain only alphanumeric characters".to_string());
}
Ok(())
}
Configuration Management
#[derive(Debug, serde::Deserialize)]
pub struct ClientConfig {
pub sender_comp_id: String,
pub target_comp_id: String,
pub default_account: String,
pub fix_version: String,
}
impl ClientConfig {
fn load() -> anyhow::Result<Self> {
let config_str = std::fs::read_to_string("client_config.toml")?;
toml::from_str(&config_str).map_err(Into::into)
}
}
Async Network Integration
use tokio::net::TcpStream;
pub struct FixConnection {
stream: TcpStream,
encoder: Encoder,
decoder: Decoder,
}
impl FixConnection {
pub async fn connect(address: &str) -> anyhow::Result<Self> {
let stream = TcpStream::connect(address).await?;
let dictionary = Dictionary::fix42();
Ok(Self {
stream,
encoder: Encoder::default(),
decoder: Decoder::new(dictionary),
})
}
pub async fn send_order(&mut self, order: &OrderRequest, seq_num: u32) -> anyhow::Result<()> {
let message = encode_new_order_single(order, seq_num)?;
use tokio::io::AsyncWriteExt;
self.stream.write_all(&message).await?;
Ok(())
}
}
Production Considerations
- Error Recovery: Implement robust error handling and recovery mechanisms
- Session Management: Handle FIX session logistics (logon, heartbeats, sequence numbers)
- Persistence: Store orders and session state persistently
- Security: Implement proper authentication and encryption
- Monitoring: Add logging and metrics for production use
Benefits
- Type Safety: Compile-time checking of FIX field usage
- User Experience: Intuitive command-line interface with validation
- Async Performance: Non-blocking I/O for real-time trading
- Extensibility: Easy to add new order types and features
- Testing: Interactive interface makes manual testing straightforward
Next Steps
This interactive trading client provides a foundation for:
- Algorithmic trading frontends
- Risk management interfaces
- Market making tools
- Training and simulation systems
The combination of miniFIX’s type safety, Tokio’s async performance, and interactive CLI makes it suitable for both development and production trading environments.