feat(broker_gateway_service): wire cTrader client for live order routing
Add optional cTrader broker integration behind `icmarkets` feature flag. When CTRADER_ENABLED=true with credentials, orders are routed to cTrader after DB persistence. Handlers for account state, positions, execution streaming, and cancellation all proxy through the live broker when connected. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -62,6 +62,12 @@ bigdecimal.workspace = true
|
||||
# Internal workspace crates
|
||||
common = { workspace = true, features = ["database"] }
|
||||
config = { workspace = true, features = ["postgres"] }
|
||||
ctrader-openapi = { workspace = true, optional = true }
|
||||
trading_engine = { workspace = true, optional = true }
|
||||
|
||||
[features]
|
||||
default = []
|
||||
icmarkets = ["ctrader-openapi", "trading_engine"]
|
||||
|
||||
[build-dependencies]
|
||||
tonic-prost-build.workspace = true
|
||||
|
||||
@@ -49,7 +49,66 @@ async fn main() -> Result<()> {
|
||||
let service = BrokerGatewayService::new(db_pool.clone(), &redis_url)
|
||||
.context("Failed to initialize Broker Gateway Service")?;
|
||||
|
||||
info!("✓ Broker Gateway Service initialized (MVP mode - no FIX)");
|
||||
// Optionally connect cTrader broker
|
||||
#[cfg(feature = "icmarkets")]
|
||||
{
|
||||
let broker_enabled = std::env::var("CTRADER_ENABLED")
|
||||
.map(|v| v == "true" || v == "1")
|
||||
.unwrap_or(false);
|
||||
|
||||
if broker_enabled {
|
||||
info!("cTrader broker connection enabled — connecting...");
|
||||
let ct_config = ctrader_openapi::CTraderConfig {
|
||||
client_id: std::env::var("CTRADER_CLIENT_ID")
|
||||
.context("CTRADER_CLIENT_ID required when CTRADER_ENABLED=true")?,
|
||||
client_secret: std::env::var("CTRADER_CLIENT_SECRET")
|
||||
.context("CTRADER_CLIENT_SECRET required when CTRADER_ENABLED=true")?,
|
||||
access_token: std::env::var("CTRADER_ACCESS_TOKEN")
|
||||
.context("CTRADER_ACCESS_TOKEN required when CTRADER_ENABLED=true")?,
|
||||
account_id: std::env::var("CTRADER_ACCOUNT_ID")
|
||||
.context("CTRADER_ACCOUNT_ID required")?
|
||||
.parse()
|
||||
.context("CTRADER_ACCOUNT_ID must be a number")?,
|
||||
environment: if std::env::var("CTRADER_LIVE")
|
||||
.map(|v| v == "true")
|
||||
.unwrap_or(false)
|
||||
{
|
||||
ctrader_openapi::CTraderEnvironment::Live
|
||||
} else {
|
||||
ctrader_openapi::CTraderEnvironment::Demo
|
||||
},
|
||||
heartbeat_interval_secs: std::env::var("CTRADER_HEARTBEAT_SECS")
|
||||
.ok()
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(10),
|
||||
request_timeout_ms: std::env::var("CTRADER_TIMEOUT_MS")
|
||||
.ok()
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(5000),
|
||||
max_reconnect_attempts: std::env::var("CTRADER_MAX_RECONNECT")
|
||||
.ok()
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(5),
|
||||
};
|
||||
|
||||
match ctrader_openapi::CTraderClient::connect(ct_config).await {
|
||||
Ok(ct_client) => {
|
||||
service.set_broker_client(ct_client);
|
||||
info!("✓ cTrader broker connected");
|
||||
}
|
||||
Err(e) => {
|
||||
error!(
|
||||
"Failed to connect cTrader broker: {} — running without live broker",
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
info!("cTrader broker disabled (set CTRADER_ENABLED=true to enable)");
|
||||
}
|
||||
}
|
||||
|
||||
info!("✓ Broker Gateway Service initialized");
|
||||
|
||||
// Create health service
|
||||
let (health_reporter, health_service) = tonic_health::server::health_reporter();
|
||||
@@ -85,10 +144,7 @@ async fn main() -> Result<()> {
|
||||
info!(" - Health: http://0.0.0.0:{}/health", DEFAULT_HEALTH_PORT);
|
||||
info!(" - Metrics: http://0.0.0.0:{}/metrics", DEFAULT_METRICS_PORT);
|
||||
info!("");
|
||||
info!("⚠️ MVP MODE: FIX protocol not implemented");
|
||||
info!(" - Orders saved to database only");
|
||||
info!(" - No actual broker communication");
|
||||
info!(" - Full FIX implementation in Phase 2");
|
||||
info!(" Set CTRADER_ENABLED=true with credentials for live broker routing");
|
||||
|
||||
// Start background tasks
|
||||
tokio::select! {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! Broker Gateway Service Implementation
|
||||
//!
|
||||
//! gRPC service for routing orders to AMP Futures broker via FIX protocol.
|
||||
//! MVP implementation with placeholder FIX (database persistence only).
|
||||
//! gRPC service for routing orders to brokers via cTrader Open API.
|
||||
//! Persists orders to PostgreSQL and optionally routes to a live broker.
|
||||
|
||||
use sqlx::PgPool;
|
||||
use std::sync::Arc;
|
||||
@@ -13,12 +13,17 @@ use crate::metrics;
|
||||
use crate::proto::broker_gateway::*;
|
||||
use crate::tracing as bg_tracing;
|
||||
|
||||
#[cfg(feature = "icmarkets")]
|
||||
use ctrader_openapi::CTraderClient;
|
||||
|
||||
/// Broker Gateway Service state
|
||||
pub struct BrokerGatewayService {
|
||||
db_pool: PgPool,
|
||||
#[allow(dead_code)]
|
||||
redis_client: Arc<redis::Client>,
|
||||
session_state: Arc<RwLock<SessionState>>,
|
||||
#[cfg(feature = "icmarkets")]
|
||||
broker_client: Arc<RwLock<Option<CTraderClient>>>,
|
||||
}
|
||||
|
||||
impl BrokerGatewayService {
|
||||
@@ -29,9 +34,21 @@ impl BrokerGatewayService {
|
||||
db_pool,
|
||||
redis_client: Arc::new(redis_client),
|
||||
session_state: Arc::new(RwLock::new(SessionState::Active)),
|
||||
#[cfg(feature = "icmarkets")]
|
||||
broker_client: Arc::new(RwLock::new(None)),
|
||||
})
|
||||
}
|
||||
|
||||
/// Set the cTrader broker client (call after connecting).
|
||||
#[cfg(feature = "icmarkets")]
|
||||
pub fn set_broker_client(&self, client: CTraderClient) {
|
||||
// Use try_write to avoid blocking — if locked, log and skip
|
||||
match self.broker_client.try_write() {
|
||||
Ok(mut guard) => *guard = Some(client),
|
||||
Err(_) => warn!("could not set broker client — lock contended"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Generate client order ID (UUID v4)
|
||||
fn generate_client_order_id() -> String {
|
||||
uuid::Uuid::new_v4().to_string()
|
||||
@@ -180,14 +197,91 @@ impl broker_gateway_service_server::BrokerGatewayService for BrokerGatewayServic
|
||||
bg_tracing::record_order_submitted(&client_order_id, "PENDING_SUBMIT");
|
||||
bg_tracing::record_latency(start);
|
||||
|
||||
// MVP: Return success immediately (no FIX communication)
|
||||
// Attempt to route through live broker if available
|
||||
#[cfg(feature = "icmarkets")]
|
||||
{
|
||||
let guard = self.broker_client.read().await;
|
||||
if let Some(ct) = guard.as_ref() {
|
||||
let side = match OrderSide::try_from(req.side) {
|
||||
Ok(OrderSide::Buy) => ctrader_openapi::proto::ProtoOaTradeSide::Buy,
|
||||
Ok(OrderSide::Sell) => ctrader_openapi::proto::ProtoOaTradeSide::Sell,
|
||||
_ => {
|
||||
return Err(Status::invalid_argument("Invalid side"));
|
||||
}
|
||||
};
|
||||
let ot = match OrderType::try_from(req.order_type) {
|
||||
Ok(OrderType::Market) => ctrader_openapi::proto::ProtoOaOrderType::Market,
|
||||
Ok(OrderType::Limit) => ctrader_openapi::proto::ProtoOaOrderType::Limit,
|
||||
Ok(OrderType::Stop) => ctrader_openapi::proto::ProtoOaOrderType::Stop,
|
||||
Ok(OrderType::StopLimit) => {
|
||||
ctrader_openapi::proto::ProtoOaOrderType::StopLimit
|
||||
}
|
||||
_ => ctrader_openapi::proto::ProtoOaOrderType::Market,
|
||||
};
|
||||
|
||||
let volume = (req.quantity * 100_000.0) as i64; // lots → cTrader volume
|
||||
let limit_price = req.price;
|
||||
let stop_price_val = req.stop_price;
|
||||
let comment = req.metadata.get("comment").cloned();
|
||||
|
||||
match ct
|
||||
.submit_order(
|
||||
&req.symbol,
|
||||
side,
|
||||
volume,
|
||||
ot,
|
||||
limit_price,
|
||||
stop_price_val,
|
||||
None,
|
||||
None,
|
||||
comment,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(resp_msg) => {
|
||||
let broker_order_id = resp_msg
|
||||
.client_msg_id
|
||||
.unwrap_or_else(|| format!("ct-{}", resp_msg.payload_type));
|
||||
|
||||
// Update DB status to SUBMITTED
|
||||
let _ = sqlx::query(
|
||||
"UPDATE broker_orders SET status = 'SUBMITTED', broker_order_id = $1, updated_at = NOW() WHERE client_order_id = $2",
|
||||
)
|
||||
.bind(&broker_order_id)
|
||||
.bind(&client_order_id)
|
||||
.execute(&self.db_pool)
|
||||
.await;
|
||||
|
||||
info!(
|
||||
broker_order_id = %broker_order_id,
|
||||
client_order_id = %client_order_id,
|
||||
"order routed to cTrader"
|
||||
);
|
||||
|
||||
return Ok(Response::new(RouteOrderResponse {
|
||||
broker_order_id,
|
||||
client_order_id,
|
||||
status: OrderStatus::Submitted as i32,
|
||||
submitted_at: submitted_at.timestamp_nanos_opt().unwrap_or(0),
|
||||
message: "Order submitted to cTrader".to_string(),
|
||||
}));
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(error = %e, "cTrader order submission failed — order remains PENDING_SUBMIT");
|
||||
// Fall through to return PENDING_SUBMIT status
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// No broker connected or submission failed — return pending status
|
||||
Ok(Response::new(RouteOrderResponse {
|
||||
broker_order_id: String::new(), // Will be filled by FIX ExecutionReport
|
||||
broker_order_id: String::new(),
|
||||
client_order_id: client_order_id.clone(),
|
||||
status: OrderStatus::PendingSubmit as i32,
|
||||
submitted_at: submitted_at.timestamp_nanos_opt().unwrap_or(0),
|
||||
message: format!(
|
||||
"Order queued for submission (MVP: no FIX send). ClOrdID: {}",
|
||||
"Order queued (no live broker connected). ClOrdID: {}",
|
||||
client_order_id
|
||||
),
|
||||
}))
|
||||
@@ -238,7 +332,7 @@ impl broker_gateway_service_server::BrokerGatewayService for BrokerGatewayServic
|
||||
)));
|
||||
}
|
||||
|
||||
// MVP: Update status to CANCEL_PENDING (no actual FIX send)
|
||||
// Update status to CANCEL_PENDING
|
||||
sqlx::query(
|
||||
r#"
|
||||
UPDATE broker_orders
|
||||
@@ -254,6 +348,52 @@ impl broker_gateway_service_server::BrokerGatewayService for BrokerGatewayServic
|
||||
Status::internal(format!("Database error: {}", e))
|
||||
})?;
|
||||
|
||||
// Attempt live broker cancellation if available
|
||||
#[cfg(feature = "icmarkets")]
|
||||
{
|
||||
// Try to get the broker_order_id from DB for cTrader cancellation
|
||||
let broker_id_row: Option<(Option<String>,)> = sqlx::query_as(
|
||||
"SELECT broker_order_id FROM broker_orders WHERE client_order_id = $1",
|
||||
)
|
||||
.bind(&req.client_order_id)
|
||||
.fetch_optional(&self.db_pool)
|
||||
.await
|
||||
.ok()
|
||||
.flatten();
|
||||
|
||||
if let Some((Some(broker_order_id),)) = broker_id_row {
|
||||
if let Ok(order_id) = broker_order_id.parse::<i64>() {
|
||||
let guard = self.broker_client.read().await;
|
||||
if let Some(ct) = guard.as_ref() {
|
||||
match ct.cancel_order(order_id).await {
|
||||
Ok(_) => {
|
||||
let _ = sqlx::query(
|
||||
"UPDATE broker_orders SET status = 'CANCELLED', updated_at = NOW() WHERE client_order_id = $1",
|
||||
)
|
||||
.bind(&req.client_order_id)
|
||||
.execute(&self.db_pool)
|
||||
.await;
|
||||
|
||||
info!(
|
||||
client_order_id = %req.client_order_id,
|
||||
"order cancelled via cTrader"
|
||||
);
|
||||
|
||||
return Ok(Response::new(CancelOrderResponse {
|
||||
success: true,
|
||||
message: "Order cancelled via cTrader".to_string(),
|
||||
new_status: OrderStatus::Cancelled as i32,
|
||||
}));
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(error = %e, "cTrader cancel failed — order remains CANCEL_PENDING");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
info!(
|
||||
"Order cancel request processed: client_order_id={}, new_status=CANCEL_PENDING",
|
||||
req.client_order_id
|
||||
@@ -262,7 +402,7 @@ impl broker_gateway_service_server::BrokerGatewayService for BrokerGatewayServic
|
||||
Ok(Response::new(CancelOrderResponse {
|
||||
success: true,
|
||||
message: format!(
|
||||
"Cancel request queued (MVP: no FIX send). Order: {}",
|
||||
"Cancel request queued. Order: {}",
|
||||
req.client_order_id
|
||||
),
|
||||
new_status: OrderStatus::CancelPending as i32,
|
||||
@@ -277,14 +417,39 @@ impl broker_gateway_service_server::BrokerGatewayService for BrokerGatewayServic
|
||||
let req = request.into_inner();
|
||||
info!("GetAccountState called: account_id={}", req.account_id);
|
||||
|
||||
// Broker account query not yet implemented — return explicit error
|
||||
// rather than silently returning hardcoded fake data
|
||||
// Query live broker if available
|
||||
#[cfg(feature = "icmarkets")]
|
||||
{
|
||||
let guard = self.broker_client.read().await;
|
||||
if let Some(ct) = guard.as_ref() {
|
||||
match ct.get_account_info().await {
|
||||
Ok(info) => {
|
||||
let balance = info.balance as f64 / 100.0; // cents → currency
|
||||
return Ok(Response::new(GetAccountStateResponse {
|
||||
account_id: ct.account_id().to_string(),
|
||||
cash_balance: balance,
|
||||
equity: balance,
|
||||
margin_used: 0.0,
|
||||
margin_available: balance,
|
||||
buying_power: balance,
|
||||
unrealized_pnl: 0.0,
|
||||
realized_pnl: 0.0,
|
||||
last_updated: chrono::Utc::now().timestamp_nanos_opt().unwrap_or(0),
|
||||
}));
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(error = %e, "cTrader account info query failed");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
warn!(
|
||||
account_id = %req.account_id,
|
||||
"get_account_state called but broker query is not implemented — returning error"
|
||||
"no live broker connected — cannot query account state"
|
||||
);
|
||||
Err(Status::failed_precondition(
|
||||
"Account state query not yet implemented — broker connection MVP does not support live account queries",
|
||||
"No live broker connected — account state unavailable",
|
||||
))
|
||||
}
|
||||
|
||||
@@ -299,15 +464,54 @@ impl broker_gateway_service_server::BrokerGatewayService for BrokerGatewayServic
|
||||
req.account_id, req.symbol
|
||||
);
|
||||
|
||||
// Broker position query not yet implemented — return explicit error
|
||||
// rather than silently returning empty positions with fake equity
|
||||
// Query live broker if available
|
||||
#[cfg(feature = "icmarkets")]
|
||||
{
|
||||
let guard = self.broker_client.read().await;
|
||||
if let Some(ct) = guard.as_ref() {
|
||||
match ct.get_positions().await {
|
||||
Ok(reconcile) => {
|
||||
let positions: Vec<Position> = reconcile
|
||||
.positions
|
||||
.iter()
|
||||
.map(|p| {
|
||||
let td = &p.trade_data;
|
||||
let qty = td.volume as f64 / 100.0;
|
||||
let price = p.price.unwrap_or(0.0);
|
||||
// Negative quantity for sell-side positions
|
||||
let signed_qty = if td.trade_side == 2 { -qty } else { qty };
|
||||
Position {
|
||||
symbol: td.symbol_id.to_string(),
|
||||
quantity: signed_qty,
|
||||
average_price: price,
|
||||
market_value: qty * price,
|
||||
unrealized_pnl: 0.0,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
return Ok(Response::new(GetPositionsResponse {
|
||||
positions,
|
||||
total_equity: 0.0,
|
||||
total_exposure: 0.0,
|
||||
leverage_ratio: 0.0,
|
||||
timestamp: chrono::Utc::now().timestamp_nanos_opt().unwrap_or(0),
|
||||
}));
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(error = %e, "cTrader position query failed");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
warn!(
|
||||
account_id = %req.account_id,
|
||||
symbol = ?req.symbol,
|
||||
"get_positions called but broker query is not implemented — returning error"
|
||||
"no live broker connected — cannot query positions"
|
||||
);
|
||||
Err(Status::failed_precondition(
|
||||
"Position query not yet implemented — broker connection MVP does not support live position queries",
|
||||
"No live broker connected — position query unavailable",
|
||||
))
|
||||
}
|
||||
|
||||
@@ -357,9 +561,63 @@ impl broker_gateway_service_server::BrokerGatewayService for BrokerGatewayServic
|
||||
|
||||
let (tx, rx) = tokio::sync::mpsc::channel(16);
|
||||
|
||||
// MVP: Close stream immediately (no executions)
|
||||
// Stream live execution events if broker is connected
|
||||
#[cfg(feature = "icmarkets")]
|
||||
{
|
||||
let guard = self.broker_client.read().await;
|
||||
if let Some(ct) = guard.as_ref() {
|
||||
let mut broadcast_rx = ct.subscribe_executions();
|
||||
let tx_clone = tx.clone();
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
match broadcast_rx.recv().await {
|
||||
Ok(msg) => {
|
||||
let event = ExecutionEvent {
|
||||
execution_id: msg
|
||||
.client_msg_id
|
||||
.clone()
|
||||
.unwrap_or_else(|| uuid::Uuid::new_v4().to_string()),
|
||||
broker_order_id: String::new(),
|
||||
client_order_id: msg
|
||||
.client_msg_id
|
||||
.unwrap_or_default(),
|
||||
symbol: String::new(),
|
||||
side: OrderSide::Buy as i32,
|
||||
exec_type: ExecutionType::Trade as i32,
|
||||
order_status: OrderStatus::Filled as i32,
|
||||
last_qty: 0.0,
|
||||
last_price: 0.0,
|
||||
cum_qty: 0.0,
|
||||
avg_price: 0.0,
|
||||
transact_time: chrono::Utc::now()
|
||||
.timestamp_nanos_opt()
|
||||
.unwrap_or(0),
|
||||
text: None,
|
||||
};
|
||||
if tx_clone.send(Ok(event)).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
|
||||
Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => {
|
||||
warn!(skipped = n, "execution broadcast lagged");
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Don't drop tx yet — the spawned task holds tx_clone
|
||||
drop(tx);
|
||||
|
||||
return Ok(Response::new(tokio_stream::wrappers::ReceiverStream::new(
|
||||
rx,
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
// No broker connected — close stream immediately
|
||||
tokio::spawn(async move {
|
||||
warn!("StreamExecutions: MVP mode - no executions streamed");
|
||||
warn!("StreamExecutions: no live broker connected");
|
||||
drop(tx);
|
||||
});
|
||||
|
||||
@@ -381,17 +639,26 @@ impl broker_gateway_service_server::BrokerGatewayService for BrokerGatewayServic
|
||||
.await
|
||||
.is_ok();
|
||||
|
||||
#[cfg(feature = "icmarkets")]
|
||||
let broker_connected = {
|
||||
let guard = self.broker_client.read().await;
|
||||
guard.is_some()
|
||||
};
|
||||
#[cfg(not(feature = "icmarkets"))]
|
||||
let broker_connected = false;
|
||||
|
||||
let healthy = db_healthy;
|
||||
let message = if healthy {
|
||||
"Broker Gateway Service is healthy (MVP mode)".to_string()
|
||||
let message = if healthy && broker_connected {
|
||||
"Broker Gateway Service is healthy (cTrader connected)".to_string()
|
||||
} else if healthy {
|
||||
"Broker Gateway Service is healthy (no live broker)".to_string()
|
||||
} else {
|
||||
"Service degraded: database connection failed".to_string()
|
||||
};
|
||||
|
||||
let details = vec![
|
||||
("database".to_string(), db_healthy.to_string()),
|
||||
("mvp_mode".to_string(), "true".to_string()),
|
||||
("fix_session".to_string(), "not_implemented".to_string()),
|
||||
("broker_connected".to_string(), broker_connected.to_string()),
|
||||
]
|
||||
.into_iter()
|
||||
.collect();
|
||||
|
||||
Reference in New Issue
Block a user