- Add dimension validation in DQN, PPO, Mamba2, TGGN, TLOB, Liquid, KAN, xLSTM, Diffusion constructors (fail-fast on zero-dim inputs that would cause CUDA_ERROR_INVALID_VALUE at runtime) - Add num_unknown_features > 0 guard to TFT (temporal input required) - Fix 12 dead-code/unused warnings in test compilation - Remove opt-level=3 and codegen-units=1 from target rustflags (was forcing O3 + single-thread codegen on dev/test builds) - Remove hardcoded jobs=16 cap (cargo now auto-detects CPU count) - Switch linker to clang+lld (2-5x faster linking) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1585 lines
55 KiB
Rust
1585 lines
55 KiB
Rust
//! PostgreSQL repository implementations
|
|
//!
|
|
//! This module provides concrete implementations of the repository traits using PostgreSQL
|
|
//! as the underlying data store. These implementations handle all database operations
|
|
//! and provide the data access layer for the Trading Service.
|
|
|
|
use crate::error::{TradingServiceError, TradingServiceResult};
|
|
use crate::proto::trading::*;
|
|
use crate::repositories::*;
|
|
use async_trait::async_trait;
|
|
use common::PriceLevel;
|
|
use sqlx::{PgPool, Row};
|
|
|
|
/// Helper function to safely convert Unix timestamp to DateTime
|
|
///
|
|
/// Returns TimestampConversion error if timestamp is out of valid range
|
|
#[inline]
|
|
fn safe_timestamp_to_datetime(
|
|
timestamp: i64,
|
|
) -> TradingServiceResult<chrono::DateTime<chrono::Utc>> {
|
|
chrono::DateTime::from_timestamp(timestamp, 0)
|
|
.ok_or(TradingServiceError::TimestampConversion { timestamp })
|
|
}
|
|
|
|
/// Postgre`SQL` implementation of TradingRepository
|
|
#[derive(Debug, Clone)]
|
|
pub struct PostgresTradingRepository {
|
|
pool: PgPool,
|
|
}
|
|
|
|
impl PostgresTradingRepository {
|
|
/// Create new Postgre`SQL` trading repository
|
|
pub fn new(pool: PgPool) -> Self {
|
|
Self { pool }
|
|
}
|
|
|
|
/// Get reference to database pool (for direct queries in service layer)
|
|
pub fn pool(&self) -> &PgPool {
|
|
&self.pool
|
|
}
|
|
}
|
|
|
|
#[async_trait]
|
|
impl TradingRepository for PostgresTradingRepository {
|
|
async fn store_order(&self, order: &TradingOrder) -> TradingServiceResult<String> {
|
|
let order_id = uuid::Uuid::new_v4();
|
|
|
|
// Use direct sqlx query binding instead of trait objects
|
|
// Convert float prices to BIGINT (cents) - multiply by 100 for cent precision
|
|
// Market orders must have NULL limit_price per database constraint
|
|
let limit_price_cents = if order.order_type == common::OrderType::Market {
|
|
None
|
|
} else {
|
|
let price_cents = order.price * 100.0;
|
|
if !price_cents.is_finite()
|
|
|| price_cents < i64::MIN as f64
|
|
|| price_cents > i64::MAX as f64
|
|
{
|
|
return Err(TradingServiceError::ValidationError {
|
|
message: format!(
|
|
"Price overflow: {} cannot be safely converted to i64",
|
|
order.price
|
|
),
|
|
});
|
|
}
|
|
Some(price_cents as i64)
|
|
};
|
|
let stop_price_cents = order.stop_price.map(|p| {
|
|
let price_cents = p * 100.0;
|
|
if !price_cents.is_finite()
|
|
|| price_cents < i64::MIN as f64
|
|
|| price_cents > i64::MAX as f64
|
|
{
|
|
i64::MAX // Clamp to max value if overflow
|
|
} else {
|
|
price_cents as i64
|
|
}
|
|
});
|
|
|
|
// Convert quantity to BIGINT (base units)
|
|
let quantity_cents = order.quantity * 100.0;
|
|
if !quantity_cents.is_finite() || quantity_cents < 0.0 || quantity_cents > i64::MAX as f64 {
|
|
return Err(TradingServiceError::ValidationError {
|
|
message: format!(
|
|
"Quantity overflow: {} cannot be safely converted to i64",
|
|
order.quantity
|
|
),
|
|
});
|
|
}
|
|
let quantity_bigint = quantity_cents as i64;
|
|
|
|
// Use nanosecond timestamp directly
|
|
let timestamp_ns = order.timestamp * 1_000_000_000; // Convert seconds to nanoseconds
|
|
|
|
// Convert enums to PostgreSQL enum strings
|
|
let side_str = match order.side {
|
|
common::OrderSide::Buy => "buy",
|
|
common::OrderSide::Sell => "sell",
|
|
};
|
|
|
|
let order_type_str = match order.order_type {
|
|
common::OrderType::Market => "market",
|
|
common::OrderType::Limit => "limit",
|
|
common::OrderType::Stop => "stop",
|
|
common::OrderType::StopLimit => "stop_limit",
|
|
_ => "market", // Default fallback
|
|
};
|
|
|
|
let status_str = match order.status {
|
|
common::OrderStatus::Pending => "pending",
|
|
common::OrderStatus::New => "accepted",
|
|
common::OrderStatus::Rejected => "rejected",
|
|
common::OrderStatus::PartiallyFilled => "partial",
|
|
common::OrderStatus::Filled => "filled",
|
|
common::OrderStatus::Cancelled => "cancelled",
|
|
common::OrderStatus::Expired => "expired",
|
|
common::OrderStatus::Submitted => "submitted",
|
|
common::OrderStatus::Created => "created",
|
|
common::OrderStatus::Working => "working",
|
|
common::OrderStatus::Unknown => "unknown",
|
|
common::OrderStatus::Suspended => "suspended",
|
|
common::OrderStatus::PendingCancel => "pending_cancel",
|
|
common::OrderStatus::PendingReplace => "pending_replace",
|
|
_ => "unknown", // Catch-all for non-exhaustive enum
|
|
};
|
|
|
|
let query = r#"
|
|
INSERT INTO orders (id, account_id, symbol, side, order_type, quantity, limit_price, stop_price, status, created_at, updated_at, venue)
|
|
VALUES ($1, $2, $3, $4::order_side, $5::order_type, $6, $7, $8, $9::order_status, $10, $10, $11)
|
|
"#;
|
|
|
|
sqlx::query(query)
|
|
.bind(order_id)
|
|
.bind(&order.account_id)
|
|
.bind(&order.symbol)
|
|
.bind(side_str)
|
|
.bind(order_type_str)
|
|
.bind(quantity_bigint)
|
|
.bind(limit_price_cents)
|
|
.bind(stop_price_cents)
|
|
.bind(status_str)
|
|
.bind(timestamp_ns)
|
|
.bind("SIMULATED") // venue - required field
|
|
.execute(&self.pool)
|
|
.await
|
|
.map_err(|e| TradingServiceError::DatabaseError {
|
|
source: Box::new(e),
|
|
})?;
|
|
|
|
Ok(order_id.to_string())
|
|
}
|
|
|
|
async fn update_order_status(
|
|
&self,
|
|
order_id: &str,
|
|
status: common::types::OrderStatus,
|
|
) -> TradingServiceResult<()> {
|
|
let status_str = match status {
|
|
common::OrderStatus::Pending => "pending",
|
|
common::OrderStatus::New => "accepted",
|
|
common::OrderStatus::Rejected => "rejected",
|
|
common::OrderStatus::PartiallyFilled => "partial",
|
|
common::OrderStatus::Filled => "filled",
|
|
common::OrderStatus::Cancelled => "cancelled",
|
|
common::OrderStatus::Expired => "expired",
|
|
common::OrderStatus::Submitted => "submitted",
|
|
common::OrderStatus::Created => "created",
|
|
common::OrderStatus::Working => "working",
|
|
common::OrderStatus::Unknown => "unknown",
|
|
common::OrderStatus::Suspended => "suspended",
|
|
common::OrderStatus::PendingCancel => "pending_cancel",
|
|
common::OrderStatus::PendingReplace => "pending_replace",
|
|
_ => "unknown", // Catch-all for non-exhaustive enum
|
|
};
|
|
|
|
let timestamp_ns = chrono::Utc::now().timestamp() * 1_000_000_000;
|
|
let query =
|
|
"UPDATE orders SET status = $1::order_status, updated_at = $2 WHERE id = $3::uuid";
|
|
|
|
sqlx::query(query)
|
|
.bind(status_str)
|
|
.bind(timestamp_ns)
|
|
.bind(order_id)
|
|
.execute(&self.pool)
|
|
.await
|
|
.map_err(|e| TradingServiceError::DatabaseError {
|
|
source: Box::new(e),
|
|
})?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
async fn get_order(&self, order_id: &str) -> TradingServiceResult<Option<TradingOrder>> {
|
|
let query = "SELECT id::uuid::text as id, account_id, symbol, side::text, order_type::text, quantity, limit_price, stop_price, filled_quantity, status::text, created_at FROM orders WHERE id = $1::uuid";
|
|
|
|
let row = sqlx::query(query)
|
|
.bind(order_id)
|
|
.fetch_optional(&self.pool)
|
|
.await
|
|
.map_err(|e| TradingServiceError::DatabaseError {
|
|
source: Box::new(e),
|
|
})?;
|
|
|
|
if let Some(row) = row {
|
|
// Convert BIGINT back to f64
|
|
let quantity_bigint: i64 = row.get("quantity");
|
|
let limit_price_bigint: Option<i64> = row.get("limit_price");
|
|
let stop_price_bigint: Option<i64> = row.get("stop_price");
|
|
let filled_quantity_bigint: i64 = row.get("filled_quantity");
|
|
let created_at_ns: i64 = row.get("created_at");
|
|
|
|
// Parse enum strings from PostgreSQL
|
|
let side_str: String = row.get("side");
|
|
let order_type_str: String = row.get("order_type");
|
|
let status_str: String = row.get("status");
|
|
|
|
let side = match side_str.as_str() {
|
|
"buy" => common::OrderSide::Buy,
|
|
"sell" => common::OrderSide::Sell,
|
|
_ => common::OrderSide::Buy,
|
|
};
|
|
|
|
let order_type = match order_type_str.as_str() {
|
|
"market" => common::OrderType::Market,
|
|
"limit" => common::OrderType::Limit,
|
|
"stop" => common::OrderType::Stop,
|
|
"stop_limit" => common::OrderType::StopLimit,
|
|
_ => common::OrderType::Market,
|
|
};
|
|
|
|
let status = match status_str.as_str() {
|
|
"pending" => common::OrderStatus::Pending,
|
|
"accepted" => common::OrderStatus::New,
|
|
"rejected" => common::OrderStatus::Rejected,
|
|
"partial" => common::OrderStatus::PartiallyFilled,
|
|
"filled" => common::OrderStatus::Filled,
|
|
"cancelled" => common::OrderStatus::Cancelled,
|
|
"expired" => common::OrderStatus::Expired,
|
|
"submitted" => common::OrderStatus::Submitted,
|
|
"created" => common::OrderStatus::Created,
|
|
"working" => common::OrderStatus::Working,
|
|
"unknown" => common::OrderStatus::Unknown,
|
|
"suspended" => common::OrderStatus::Suspended,
|
|
"pending_cancel" => common::OrderStatus::PendingCancel,
|
|
"pending_replace" => common::OrderStatus::PendingReplace,
|
|
_ => common::OrderStatus::Pending,
|
|
};
|
|
|
|
Ok(Some(TradingOrder {
|
|
id: row.get("id"),
|
|
account_id: row.get("account_id"),
|
|
symbol: row.get("symbol"),
|
|
side,
|
|
order_type,
|
|
quantity: quantity_bigint as f64 / 100.0,
|
|
filled_quantity: filled_quantity_bigint as f64 / 100.0,
|
|
price: limit_price_bigint.map(|p| p as f64 / 100.0).unwrap_or(0.0),
|
|
stop_price: stop_price_bigint.map(|p| p as f64 / 100.0),
|
|
status,
|
|
timestamp: created_at_ns / 1_000_000_000, // Convert nanoseconds to seconds
|
|
}))
|
|
} else {
|
|
Ok(None)
|
|
}
|
|
}
|
|
|
|
async fn get_orders_for_account(
|
|
&self,
|
|
account_id: &str,
|
|
) -> TradingServiceResult<Vec<TradingOrder>> {
|
|
let rows = sqlx::query(
|
|
"SELECT id::uuid::text as id, account_id, symbol, side::text, order_type::text, quantity, limit_price, stop_price, filled_quantity, status::text, created_at FROM orders WHERE account_id = $1 ORDER BY created_at DESC"
|
|
)
|
|
.bind(account_id)
|
|
.fetch_all(&self.pool)
|
|
.await
|
|
.map_err(|e| TradingServiceError::DatabaseError { source: Box::new(e) })?;
|
|
|
|
let orders = rows
|
|
.into_iter()
|
|
.map(|row| {
|
|
// Convert BIGINT back to f64
|
|
let quantity_bigint: i64 = row.get("quantity");
|
|
let limit_price_bigint: Option<i64> = row.get("limit_price");
|
|
let stop_price_bigint: Option<i64> = row.get("stop_price");
|
|
let filled_quantity_bigint: i64 = row.get("filled_quantity");
|
|
let created_at_ns: i64 = row.get("created_at");
|
|
|
|
// Parse enum strings from PostgreSQL
|
|
let side_str: String = row.get("side");
|
|
let order_type_str: String = row.get("order_type");
|
|
let status_str: String = row.get("status");
|
|
|
|
let side = match side_str.as_str() {
|
|
"buy" => common::OrderSide::Buy,
|
|
"sell" => common::OrderSide::Sell,
|
|
_ => common::OrderSide::Buy,
|
|
};
|
|
|
|
let order_type = match order_type_str.as_str() {
|
|
"market" => common::OrderType::Market,
|
|
"limit" => common::OrderType::Limit,
|
|
"stop" => common::OrderType::Stop,
|
|
"stop_limit" => common::OrderType::StopLimit,
|
|
_ => common::OrderType::Market,
|
|
};
|
|
|
|
let status = match status_str.as_str() {
|
|
"pending" => common::OrderStatus::Pending,
|
|
"accepted" => common::OrderStatus::New,
|
|
"rejected" => common::OrderStatus::Rejected,
|
|
"partial" => common::OrderStatus::PartiallyFilled,
|
|
"filled" => common::OrderStatus::Filled,
|
|
"cancelled" => common::OrderStatus::Cancelled,
|
|
"expired" => common::OrderStatus::Expired,
|
|
"submitted" => common::OrderStatus::Submitted,
|
|
"created" => common::OrderStatus::Created,
|
|
"working" => common::OrderStatus::Working,
|
|
"unknown" => common::OrderStatus::Unknown,
|
|
"suspended" => common::OrderStatus::Suspended,
|
|
"pending_cancel" => common::OrderStatus::PendingCancel,
|
|
"pending_replace" => common::OrderStatus::PendingReplace,
|
|
_ => common::OrderStatus::Pending,
|
|
};
|
|
|
|
TradingOrder {
|
|
id: row.get("id"),
|
|
account_id: row.get("account_id"),
|
|
symbol: row.get("symbol"),
|
|
side,
|
|
order_type,
|
|
quantity: quantity_bigint as f64 / 100.0,
|
|
filled_quantity: filled_quantity_bigint as f64 / 100.0,
|
|
price: limit_price_bigint.map(|p| p as f64 / 100.0).unwrap_or(0.0),
|
|
stop_price: stop_price_bigint.map(|p| p as f64 / 100.0),
|
|
status,
|
|
timestamp: created_at_ns / 1_000_000_000,
|
|
}
|
|
})
|
|
.collect();
|
|
|
|
Ok(orders)
|
|
}
|
|
|
|
async fn store_execution(
|
|
&self,
|
|
execution: &crate::repositories::ExecutionEvent,
|
|
) -> TradingServiceResult<()> {
|
|
sqlx::query(
|
|
r#"
|
|
INSERT INTO executions (id, order_id, account_id, symbol, side, quantity, price, timestamp)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
|
"#
|
|
)
|
|
.bind(&execution.id)
|
|
.bind(&execution.order_id)
|
|
.bind(&execution.account_id)
|
|
.bind(&execution.symbol)
|
|
.bind(match execution.side { common::OrderSide::Buy => 0, common::OrderSide::Sell => 1 })
|
|
.bind(execution.quantity)
|
|
.bind(execution.price)
|
|
.bind(safe_timestamp_to_datetime(execution.timestamp)?)
|
|
.execute(&self.pool)
|
|
.await
|
|
.map_err(|e| TradingServiceError::DatabaseError { source: Box::new(e) })?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
async fn get_execution_history(
|
|
&self,
|
|
request: &GetExecutionHistoryRequest,
|
|
) -> TradingServiceResult<Vec<crate::repositories::ExecutionEvent>> {
|
|
let rows = sqlx::query(
|
|
"SELECT id::uuid::text as id, order_id::uuid::text as order_id, account_id, symbol, side, quantity, price, EXTRACT(EPOCH FROM timestamp)::bigint as timestamp FROM executions WHERE account_id = $1 ORDER BY timestamp DESC LIMIT 1000"
|
|
)
|
|
.bind(request.account_id.as_deref().unwrap_or(""))
|
|
.fetch_all(&self.pool)
|
|
.await
|
|
.map_err(|e| TradingServiceError::DatabaseError { source: Box::new(e) })?;
|
|
|
|
let executions = rows
|
|
.into_iter()
|
|
.map(|row| crate::repositories::ExecutionEvent {
|
|
id: row.get("id"),
|
|
order_id: row.get("order_id"),
|
|
account_id: row.get("account_id"),
|
|
symbol: row.get("symbol"),
|
|
side: common::OrderSide::try_from(row.get::<i32, _>("side"))
|
|
.unwrap_or(common::OrderSide::Buy),
|
|
quantity: row.get("quantity"),
|
|
price: row.get("price"),
|
|
timestamp: row.get::<Option<i64>, _>("timestamp").unwrap_or(0),
|
|
})
|
|
.collect();
|
|
|
|
Ok(executions)
|
|
}
|
|
|
|
async fn store_position(&self, position: &TradingPosition) -> TradingServiceResult<()> {
|
|
sqlx::query(
|
|
r#"
|
|
INSERT INTO positions (account_id, symbol, quantity, average_price, market_value, unrealized_pnl, timestamp)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
|
ON CONFLICT (account_id, symbol) DO UPDATE SET
|
|
quantity = EXCLUDED.quantity,
|
|
average_price = EXCLUDED.average_price,
|
|
market_value = EXCLUDED.market_value,
|
|
unrealized_pnl = EXCLUDED.unrealized_pnl,
|
|
timestamp = EXCLUDED.timestamp
|
|
"#
|
|
)
|
|
.bind(&position.account_id)
|
|
.bind(&position.symbol)
|
|
.bind(position.quantity)
|
|
.bind(position.average_price)
|
|
.bind(position.market_value)
|
|
.bind(position.unrealized_pnl)
|
|
.bind(safe_timestamp_to_datetime(position.timestamp)?)
|
|
.execute(&self.pool)
|
|
.await
|
|
.map_err(|e| TradingServiceError::DatabaseError { source: Box::new(e) })?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
async fn get_positions(
|
|
&self,
|
|
account_id: Option<&str>,
|
|
symbol: Option<&str>,
|
|
) -> TradingServiceResult<Vec<TradingPosition>> {
|
|
let mut query = "SELECT account_id, symbol, quantity, avg_cost as average_price, market_value, unrealized_pnl, (last_updated / 1000000000)::bigint as timestamp FROM positions WHERE 1=1".to_string();
|
|
let mut params = Vec::new();
|
|
let mut param_count = 1;
|
|
|
|
if let Some(account) = account_id {
|
|
query.push_str(&format!(" AND account_id = ${}", param_count));
|
|
params.push(account);
|
|
param_count += 1;
|
|
}
|
|
|
|
if let Some(sym) = symbol {
|
|
query.push_str(&format!(" AND symbol = ${}", param_count));
|
|
params.push(sym);
|
|
}
|
|
|
|
query.push_str(" ORDER BY last_updated DESC");
|
|
|
|
// For simplicity, using a basic query - in production would use proper parameter binding
|
|
let rows = if let (Some(account), Some(sym)) = (account_id, symbol) {
|
|
sqlx::query(
|
|
"SELECT account_id, symbol, quantity, avg_cost as average_price, market_value, unrealized_pnl, (last_updated / 1000000000)::bigint as timestamp FROM positions WHERE account_id = $1 AND symbol = $2 ORDER BY last_updated DESC"
|
|
)
|
|
.bind(account)
|
|
.bind(sym)
|
|
.fetch_all(&self.pool)
|
|
.await
|
|
} else if let Some(account) = account_id {
|
|
sqlx::query(
|
|
"SELECT account_id, symbol, quantity, avg_cost as average_price, market_value, unrealized_pnl, (last_updated / 1000000000)::bigint as timestamp FROM positions WHERE account_id = $1 ORDER BY last_updated DESC"
|
|
)
|
|
.bind(account)
|
|
.fetch_all(&self.pool)
|
|
.await
|
|
} else {
|
|
sqlx::query(
|
|
"SELECT account_id, symbol, quantity, avg_cost as average_price, market_value, unrealized_pnl, (last_updated / 1000000000)::bigint as timestamp FROM positions ORDER BY last_updated DESC"
|
|
)
|
|
.fetch_all(&self.pool)
|
|
.await
|
|
}
|
|
.map_err(|e| TradingServiceError::DatabaseError { source: Box::new(e) })?;
|
|
|
|
let positions = rows
|
|
.into_iter()
|
|
.map(|row| TradingPosition {
|
|
account_id: row.get("account_id"),
|
|
symbol: row.get("symbol"),
|
|
quantity: row.get("quantity"),
|
|
average_price: row.get("average_price"),
|
|
market_value: row.get("market_value"),
|
|
unrealized_pnl: row.get("unrealized_pnl"),
|
|
timestamp: row.get::<Option<i64>, _>("timestamp").unwrap_or(0),
|
|
})
|
|
.collect();
|
|
|
|
Ok(positions)
|
|
}
|
|
|
|
async fn get_portfolio_summary(
|
|
&self,
|
|
account_id: &str,
|
|
) -> TradingServiceResult<PortfolioSummary> {
|
|
let row = sqlx::query(
|
|
r#"
|
|
SELECT
|
|
COALESCE(SUM(market_value), 0.0)::DOUBLE PRECISION as total_value,
|
|
COALESCE(SUM(unrealized_pnl), 0.0)::DOUBLE PRECISION as unrealized_pnl,
|
|
COALESCE(SUM(CASE WHEN quantity > 0 THEN market_value ELSE 0 END), 0.0)::DOUBLE PRECISION as positions_value
|
|
FROM positions
|
|
WHERE account_id = $1
|
|
"#
|
|
)
|
|
.bind(account_id)
|
|
.fetch_one(&self.pool)
|
|
.await
|
|
.map_err(|e| TradingServiceError::DatabaseError { source: Box::new(e) })?;
|
|
|
|
// Get realized PnL from executions (simplified calculation)
|
|
let realized_pnl_row = sqlx::query(
|
|
"SELECT COALESCE(SUM(quantity * price), 0.0)::DOUBLE PRECISION as realized_pnl FROM executions WHERE account_id = $1"
|
|
)
|
|
.bind(account_id)
|
|
.fetch_one(&self.pool)
|
|
.await
|
|
.map_err(|e| TradingServiceError::DatabaseError { source: Box::new(e) })?;
|
|
|
|
// CRITICAL: Get actual cash balance from database - NO HARDCODED DEFAULTS
|
|
let cash_balance = sqlx::query_scalar::<_, f64>(
|
|
"SELECT COALESCE(cash_balance, 0.0) FROM account_balances WHERE account_id = $1"
|
|
)
|
|
.bind(account_id)
|
|
.fetch_optional(&self.pool)
|
|
.await
|
|
.map_err(|e| TradingServiceError::DatabaseError { source: Box::new(e) })?
|
|
.ok_or_else(|| TradingServiceError::ConfigurationError {
|
|
message: format!("CRITICAL: No cash balance found for account {} - cannot create portfolio summary with hardcoded defaults", account_id)
|
|
})?;
|
|
|
|
Ok(PortfolioSummary {
|
|
account_id: account_id.to_string(),
|
|
total_value: row.get::<Option<f64>, _>("total_value").unwrap_or(0.0),
|
|
cash_balance,
|
|
positions_value: row.get::<Option<f64>, _>("positions_value").unwrap_or(0.0),
|
|
unrealized_pnl: row.get::<Option<f64>, _>("unrealized_pnl").unwrap_or(0.0),
|
|
realized_pnl: realized_pnl_row
|
|
.get::<Option<f64>, _>("realized_pnl")
|
|
.unwrap_or(0.0),
|
|
})
|
|
}
|
|
|
|
async fn get_realized_pnl(
|
|
&self,
|
|
account_id: &str,
|
|
symbol: Option<&str>,
|
|
) -> TradingServiceResult<f64> {
|
|
let query = if let Some(sym) = symbol {
|
|
sqlx::query_scalar::<_, Option<f64>>(
|
|
"SELECT SUM(quantity * price) FROM executions WHERE account_id = $1 AND symbol = $2"
|
|
)
|
|
.bind(account_id)
|
|
.bind(sym)
|
|
} else {
|
|
sqlx::query_scalar::<_, Option<f64>>(
|
|
"SELECT SUM(quantity * price) FROM executions WHERE account_id = $1",
|
|
)
|
|
.bind(account_id)
|
|
};
|
|
|
|
let result = query.fetch_optional(&self.pool).await.map_err(|e| {
|
|
TradingServiceError::DatabaseError {
|
|
source: Box::new(e),
|
|
}
|
|
})?;
|
|
|
|
Ok(result.flatten().unwrap_or(0.0))
|
|
}
|
|
|
|
async fn get_day_pnl(&self, account_id: &str) -> TradingServiceResult<f64> {
|
|
let result = sqlx::query_scalar::<_, Option<f64>>(
|
|
r#"
|
|
SELECT SUM(quantity * price)
|
|
FROM executions
|
|
WHERE account_id = $1
|
|
AND DATE(timestamp) = CURRENT_DATE
|
|
"#,
|
|
)
|
|
.bind(account_id)
|
|
.fetch_optional(&self.pool)
|
|
.await
|
|
.map_err(|e| TradingServiceError::DatabaseError {
|
|
source: Box::new(e),
|
|
})?;
|
|
|
|
Ok(result.flatten().unwrap_or(0.0))
|
|
}
|
|
}
|
|
|
|
/// Postgre`SQL` implementation of MarketDataRepository
|
|
#[derive(Debug, Clone)]
|
|
pub struct PostgresMarketDataRepository {
|
|
pool: PgPool,
|
|
}
|
|
|
|
impl PostgresMarketDataRepository {
|
|
pub fn new(pool: PgPool) -> Self {
|
|
Self { pool }
|
|
}
|
|
}
|
|
|
|
#[async_trait]
|
|
impl MarketDataRepository for PostgresMarketDataRepository {
|
|
async fn store_market_tick(
|
|
&self,
|
|
tick: &crate::repositories::MarketTick,
|
|
) -> TradingServiceResult<()> {
|
|
sqlx::query(
|
|
r#"
|
|
INSERT INTO market_ticks (symbol, price, quantity, side, timestamp)
|
|
VALUES ($1, $2, $3, $4, $5)
|
|
"#,
|
|
)
|
|
.bind(&tick.symbol)
|
|
.bind(tick.price)
|
|
.bind(tick.quantity)
|
|
.bind(tick.side.map(|s| match s {
|
|
common::OrderSide::Buy => 0,
|
|
common::OrderSide::Sell => 1,
|
|
}))
|
|
.bind(safe_timestamp_to_datetime(tick.timestamp)?)
|
|
.execute(&self.pool)
|
|
.await
|
|
.map_err(|e| TradingServiceError::DatabaseError {
|
|
source: Box::new(e),
|
|
})?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
async fn get_order_book(
|
|
&self,
|
|
symbol: &str,
|
|
depth: i32,
|
|
) -> TradingServiceResult<crate::repositories::OrderBook> {
|
|
// Simplified order book retrieval - in production would aggregate from order book table
|
|
let rows = sqlx::query(
|
|
r#"
|
|
SELECT price, quantity, side, EXTRACT(EPOCH FROM timestamp)::bigint as timestamp
|
|
FROM market_ticks
|
|
WHERE symbol = $1
|
|
ORDER BY timestamp DESC
|
|
LIMIT $2
|
|
"#,
|
|
)
|
|
.bind(symbol)
|
|
.bind(
|
|
i64::try_from(depth).map_err(|_| TradingServiceError::ValidationError {
|
|
message: format!("Depth {} out of range for i64", depth),
|
|
})?,
|
|
)
|
|
.fetch_all(&self.pool)
|
|
.await
|
|
.map_err(|e| TradingServiceError::DatabaseError {
|
|
source: Box::new(e),
|
|
})?;
|
|
|
|
let mut bids = Vec::new();
|
|
let mut asks = Vec::new();
|
|
|
|
for row in rows {
|
|
let price_level = PriceLevel {
|
|
price: row.get("price"),
|
|
size: row.get("quantity"),
|
|
};
|
|
|
|
let side: i32 = row.get("side");
|
|
if side == common::OrderSide::Buy as i32 {
|
|
bids.push(price_level);
|
|
} else {
|
|
asks.push(price_level);
|
|
}
|
|
}
|
|
|
|
Ok(crate::repositories::OrderBook {
|
|
symbol: symbol.to_string(),
|
|
bids,
|
|
asks,
|
|
timestamp: chrono::Utc::now().timestamp(),
|
|
})
|
|
}
|
|
|
|
async fn store_order_book(
|
|
&self,
|
|
symbol: &str,
|
|
order_book: &crate::repositories::OrderBook,
|
|
) -> TradingServiceResult<()> {
|
|
// In production, this would store to a dedicated order book table
|
|
// For now, store as individual price levels
|
|
for bid in &order_book.bids {
|
|
sqlx::query(
|
|
r#"
|
|
INSERT INTO order_book_levels (symbol, side, price, quantity, timestamp)
|
|
VALUES ($1, $2, $3, $4, $5)
|
|
"#,
|
|
)
|
|
.bind(symbol)
|
|
.bind(0)
|
|
.bind(bid.price)
|
|
.bind(bid.size)
|
|
.bind(safe_timestamp_to_datetime(order_book.timestamp)?)
|
|
.execute(&self.pool)
|
|
.await
|
|
.map_err(|e| TradingServiceError::DatabaseError {
|
|
source: Box::new(e),
|
|
})?;
|
|
}
|
|
|
|
for ask in &order_book.asks {
|
|
sqlx::query(
|
|
r#"
|
|
INSERT INTO order_book_levels (symbol, side, price, quantity, timestamp)
|
|
VALUES ($1, $2, $3, $4, $5)
|
|
"#,
|
|
)
|
|
.bind(symbol)
|
|
.bind(1)
|
|
.bind(ask.price)
|
|
.bind(ask.size)
|
|
.bind(safe_timestamp_to_datetime(order_book.timestamp)?)
|
|
.execute(&self.pool)
|
|
.await
|
|
.map_err(|e| TradingServiceError::DatabaseError {
|
|
source: Box::new(e),
|
|
})?;
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
async fn get_latest_prices(
|
|
&self,
|
|
symbols: &[String],
|
|
) -> TradingServiceResult<Vec<crate::repositories::MarketTick>> {
|
|
let symbol_list = symbols.join("','");
|
|
let query = format!(
|
|
r#"
|
|
SELECT DISTINCT ON (symbol) symbol, price, quantity, side, EXTRACT(EPOCH FROM timestamp)::bigint as timestamp
|
|
FROM market_ticks
|
|
WHERE symbol IN ('{}')
|
|
ORDER BY symbol, timestamp DESC
|
|
"#,
|
|
symbol_list
|
|
);
|
|
|
|
let rows = sqlx::query_as::<_, (String, f64, f64, Option<i32>, Option<i64>)>(&query)
|
|
.fetch_all(&self.pool)
|
|
.await
|
|
.map_err(|e| TradingServiceError::DatabaseError {
|
|
source: Box::new(e),
|
|
})?;
|
|
|
|
let ticks = rows
|
|
.into_iter()
|
|
.map(
|
|
|(symbol, price, quantity, side, timestamp)| crate::repositories::MarketTick {
|
|
symbol,
|
|
price,
|
|
quantity,
|
|
side: side.map(|s| match s {
|
|
0 => common::OrderSide::Buy,
|
|
_ => common::OrderSide::Sell,
|
|
}),
|
|
timestamp: timestamp.unwrap_or(0),
|
|
},
|
|
)
|
|
.collect();
|
|
|
|
Ok(ticks)
|
|
}
|
|
|
|
async fn store_market_event(
|
|
&self,
|
|
event: &common::MarketDataEvent,
|
|
) -> TradingServiceResult<()> {
|
|
sqlx::query(
|
|
r#"
|
|
INSERT INTO market_events (symbol, event_type, data, timestamp)
|
|
VALUES ($1, $2, $3, $4)
|
|
"#
|
|
)
|
|
.bind(event.symbol())
|
|
.bind(format!("{:?}", event)) // Store the enum variant as string
|
|
.bind(serde_json::to_string(event).unwrap_or_default())
|
|
.bind(event.timestamp().unwrap_or_else(chrono::Utc::now))
|
|
.execute(&self.pool)
|
|
.await
|
|
.map_err(|e| TradingServiceError::DatabaseError { source: Box::new(e) })?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
async fn get_historical_data(
|
|
&self,
|
|
symbol: &str,
|
|
from: i64,
|
|
to: i64,
|
|
) -> TradingServiceResult<Vec<crate::repositories::MarketTick>> {
|
|
let rows = sqlx::query(
|
|
r#"
|
|
SELECT symbol, price, quantity, side, EXTRACT(EPOCH FROM timestamp)::bigint as timestamp
|
|
FROM market_ticks
|
|
WHERE symbol = $1
|
|
AND timestamp >= $2
|
|
AND timestamp <= $3
|
|
ORDER BY timestamp DESC
|
|
LIMIT 10000
|
|
"#,
|
|
)
|
|
.bind(symbol)
|
|
.bind(safe_timestamp_to_datetime(from)?)
|
|
.bind(safe_timestamp_to_datetime(to)?)
|
|
.fetch_all(&self.pool)
|
|
.await
|
|
.map_err(|e| TradingServiceError::DatabaseError {
|
|
source: Box::new(e),
|
|
})?;
|
|
|
|
let ticks = rows
|
|
.into_iter()
|
|
.map(|row| crate::repositories::MarketTick {
|
|
symbol: row.get("symbol"),
|
|
price: row.get("price"),
|
|
quantity: row.get("quantity"),
|
|
side: row.get::<Option<i32>, _>("side").map(|s| match s {
|
|
0 => common::OrderSide::Buy,
|
|
_ => common::OrderSide::Sell,
|
|
}),
|
|
timestamp: row.get::<Option<i64>, _>("timestamp").unwrap_or(0),
|
|
})
|
|
.collect();
|
|
|
|
Ok(ticks)
|
|
}
|
|
|
|
async fn get_order_book_level_count(
|
|
&self,
|
|
symbol: &str,
|
|
price: f64,
|
|
side: common::OrderSide,
|
|
) -> TradingServiceResult<i32> {
|
|
let side_str = match side {
|
|
common::OrderSide::Buy => "bid",
|
|
common::OrderSide::Sell => "ask",
|
|
};
|
|
|
|
let result = sqlx::query_scalar::<_, Option<i32>>(
|
|
"SELECT order_count FROM order_book_levels WHERE symbol = $1 AND price = $2 AND side = $3"
|
|
)
|
|
.bind(symbol)
|
|
.bind(price)
|
|
.bind(side_str)
|
|
.fetch_optional(&self.pool)
|
|
.await
|
|
.map_err(|e| TradingServiceError::DatabaseError { source: Box::new(e) })?;
|
|
|
|
// Default to 1 if order count not available
|
|
Ok(result.flatten().unwrap_or(1))
|
|
}
|
|
}
|
|
|
|
/// Postgre`SQL` implementation of RiskRepository
|
|
#[derive(Debug, Clone)]
|
|
pub struct PostgresRiskRepository {
|
|
pool: PgPool,
|
|
}
|
|
|
|
impl PostgresRiskRepository {
|
|
pub fn new(pool: PgPool) -> Self {
|
|
Self { pool }
|
|
}
|
|
}
|
|
|
|
#[async_trait]
|
|
impl RiskRepository for PostgresRiskRepository {
|
|
async fn store_var_calculation(
|
|
&self,
|
|
calculation: &VarCalculation,
|
|
) -> TradingServiceResult<()> {
|
|
sqlx::query(
|
|
r#"
|
|
INSERT INTO var_calculations (account_id, var_value, confidence, time_horizon_days, timestamp)
|
|
VALUES ($1, $2, $3, $4, $5)
|
|
"#
|
|
)
|
|
.bind(&calculation.account_id)
|
|
.bind(calculation.var_value)
|
|
.bind(calculation.confidence)
|
|
.bind(calculation.time_horizon_days)
|
|
.bind(safe_timestamp_to_datetime(calculation.timestamp)?)
|
|
.execute(&self.pool)
|
|
.await
|
|
.map_err(|e| TradingServiceError::DatabaseError { source: Box::new(e) })?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
async fn get_risk_limits(&self, account_id: &str) -> TradingServiceResult<RiskLimits> {
|
|
let row = sqlx::query(
|
|
"SELECT account_id, max_order_size, max_position_limit, max_drawdown_limit, daily_loss_limit FROM risk_limits WHERE account_id = $1"
|
|
)
|
|
.bind(account_id)
|
|
.fetch_optional(&self.pool)
|
|
.await
|
|
.map_err(|e| TradingServiceError::DatabaseError { source: Box::new(e) })?;
|
|
|
|
if let Some(row) = row {
|
|
Ok(RiskLimits {
|
|
account_id: row.get("account_id"),
|
|
max_order_size: row.get("max_order_size"),
|
|
max_position_limit: row.get("max_position_limit"),
|
|
max_drawdown_limit: row.get("max_drawdown_limit"),
|
|
daily_loss_limit: row.get("daily_loss_limit"),
|
|
})
|
|
} else {
|
|
// CRITICAL: NO DEFAULT RISK LIMITS - Must be explicitly configured
|
|
return Err(TradingServiceError::ConfigurationError {
|
|
message: format!(
|
|
"CRITICAL: No risk limits found for account {} - cannot use dangerous hardcoded defaults. Risk limits must be explicitly configured in database.",
|
|
account_id
|
|
)
|
|
});
|
|
}
|
|
}
|
|
|
|
async fn update_risk_limits(
|
|
&self,
|
|
account_id: &str,
|
|
limits: &RiskLimits,
|
|
) -> TradingServiceResult<()> {
|
|
sqlx::query(
|
|
r#"
|
|
INSERT INTO risk_limits (account_id, max_order_size, max_position_limit, max_drawdown_limit, daily_loss_limit)
|
|
VALUES ($1, $2, $3, $4, $5)
|
|
ON CONFLICT (account_id) DO UPDATE SET
|
|
max_order_size = EXCLUDED.max_order_size,
|
|
max_position_limit = EXCLUDED.max_position_limit,
|
|
max_drawdown_limit = EXCLUDED.max_drawdown_limit,
|
|
daily_loss_limit = EXCLUDED.daily_loss_limit,
|
|
updated_at = NOW()
|
|
"#
|
|
)
|
|
.bind(account_id)
|
|
.bind(limits.max_order_size)
|
|
.bind(limits.max_position_limit)
|
|
.bind(limits.max_drawdown_limit)
|
|
.bind(limits.daily_loss_limit)
|
|
.execute(&self.pool)
|
|
.await
|
|
.map_err(|e| TradingServiceError::DatabaseError { source: Box::new(e) })?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
async fn store_risk_alert(&self, alert: &RiskAlert) -> TradingServiceResult<()> {
|
|
sqlx::query(
|
|
r#"
|
|
INSERT INTO risk_alerts (account_id, alert_type, message, severity, timestamp)
|
|
VALUES ($1, $2, $3, $4, $5)
|
|
"#,
|
|
)
|
|
.bind(&alert.account_id)
|
|
.bind(&alert.alert_type)
|
|
.bind(&alert.message)
|
|
.bind(&alert.severity)
|
|
.bind(safe_timestamp_to_datetime(alert.timestamp)?)
|
|
.execute(&self.pool)
|
|
.await
|
|
.map_err(|e| TradingServiceError::DatabaseError {
|
|
source: Box::new(e),
|
|
})?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
async fn get_risk_metrics(&self, account_id: &str) -> TradingServiceResult<RiskMetrics> {
|
|
// Fetch total absolute position value
|
|
let position_value: f64 = sqlx::query_scalar::<_, f64>(
|
|
"SELECT COALESCE(SUM(ABS(market_value)), 0.0) FROM positions WHERE account_id = $1",
|
|
)
|
|
.bind(account_id)
|
|
.fetch_one(&self.pool)
|
|
.await
|
|
.map_err(|e| TradingServiceError::DatabaseError {
|
|
source: Box::new(e),
|
|
})?;
|
|
|
|
let latest_var: f64 = sqlx::query_scalar(
|
|
"SELECT COALESCE(var_value, 0.0) FROM var_calculations WHERE account_id = $1 ORDER BY timestamp DESC LIMIT 1"
|
|
)
|
|
.bind(account_id)
|
|
.fetch_optional(&self.pool)
|
|
.await
|
|
.map_err(|e| TradingServiceError::DatabaseError { source: Box::new(e) })?
|
|
.unwrap_or(0.0);
|
|
|
|
// Calculate current drawdown from position PnL.
|
|
// drawdown = max(0, -total_unrealized_pnl / total_market_value)
|
|
let current_drawdown: f64 = if position_value > 0.0 {
|
|
let total_unrealized_pnl: f64 = sqlx::query_scalar::<_, f64>(
|
|
"SELECT COALESCE(SUM(unrealized_pnl), 0.0) FROM positions WHERE account_id = $1",
|
|
)
|
|
.bind(account_id)
|
|
.fetch_one(&self.pool)
|
|
.await
|
|
.map_err(|e| TradingServiceError::DatabaseError {
|
|
source: Box::new(e),
|
|
})?;
|
|
if total_unrealized_pnl < 0.0 {
|
|
(-total_unrealized_pnl) / position_value
|
|
} else {
|
|
0.0
|
|
}
|
|
} else {
|
|
tracing::warn!(
|
|
account_id = account_id,
|
|
"No position data for drawdown calculation, returning 0.0"
|
|
);
|
|
0.0
|
|
};
|
|
|
|
// Calculate leverage ratio = total_notional / account_equity.
|
|
// Account equity = account balance + unrealized PnL.
|
|
let account_equity: f64 = sqlx::query_scalar::<_, f64>(
|
|
"SELECT COALESCE(balance, 0.0) + COALESCE((SELECT SUM(unrealized_pnl) FROM positions WHERE account_id = $1), 0.0) FROM accounts WHERE account_id = $1",
|
|
)
|
|
.bind(account_id)
|
|
.fetch_optional(&self.pool)
|
|
.await
|
|
.map_err(|e| TradingServiceError::DatabaseError { source: Box::new(e) })?
|
|
.unwrap_or(0.0);
|
|
|
|
let leverage_ratio = if account_equity > 0.0 {
|
|
position_value / account_equity
|
|
} else if position_value > 0.0 {
|
|
// No account equity record but positions exist: use position_value as denominator
|
|
// (leverage >= 1.0 since notional / notional = 1.0)
|
|
tracing::warn!(
|
|
account_id = account_id,
|
|
"No account equity data, approximating leverage from position value"
|
|
);
|
|
1.0
|
|
} else {
|
|
0.0
|
|
};
|
|
|
|
// Calculate position concentration = largest single position / total portfolio value.
|
|
// Uses sum of all positions as total capital (real portfolio).
|
|
let max_single_position: f64 = sqlx::query_scalar::<_, f64>(
|
|
"SELECT COALESCE(MAX(ABS(market_value)), 0.0) FROM positions WHERE account_id = $1",
|
|
)
|
|
.bind(account_id)
|
|
.fetch_one(&self.pool)
|
|
.await
|
|
.map_err(|e| TradingServiceError::DatabaseError {
|
|
source: Box::new(e),
|
|
})?;
|
|
|
|
let position_concentration = if position_value > 0.0 {
|
|
max_single_position / position_value
|
|
} else {
|
|
0.0
|
|
};
|
|
|
|
Ok(RiskMetrics {
|
|
account_id: account_id.to_string(),
|
|
current_var: latest_var,
|
|
current_drawdown,
|
|
position_concentration,
|
|
leverage_ratio,
|
|
})
|
|
}
|
|
|
|
async fn store_position_risk(
|
|
&self,
|
|
account_id: &str,
|
|
symbol: &str,
|
|
risk: &PositionRisk,
|
|
) -> TradingServiceResult<()> {
|
|
sqlx::query(
|
|
r#"
|
|
INSERT INTO position_risks (account_id, symbol, position_var, concentration_risk, liquidity_risk, timestamp)
|
|
VALUES ($1, $2, $3, $4, $5, NOW())
|
|
ON CONFLICT (account_id, symbol) DO UPDATE SET
|
|
position_var = EXCLUDED.position_var,
|
|
concentration_risk = EXCLUDED.concentration_risk,
|
|
liquidity_risk = EXCLUDED.liquidity_risk,
|
|
timestamp = EXCLUDED.timestamp
|
|
"#
|
|
)
|
|
.bind(account_id)
|
|
.bind(symbol)
|
|
.bind(risk.position_var)
|
|
.bind(risk.concentration_risk)
|
|
.bind(risk.liquidity_risk)
|
|
.execute(&self.pool)
|
|
.await
|
|
.map_err(|e| TradingServiceError::DatabaseError { source: Box::new(e) })?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
async fn validate_order_risk(
|
|
&self,
|
|
account_id: &str,
|
|
order: &OrderRequest,
|
|
) -> TradingServiceResult<bool> {
|
|
// Get risk limits
|
|
let limits = self.get_risk_limits(account_id).await?;
|
|
|
|
// CRITICAL: Order price must be provided - NO DANGEROUS FALLBACKS
|
|
let order_price = order.price.ok_or_else(|| TradingServiceError::ConfigurationError {
|
|
message: "CRITICAL: Order price must be specified - cannot use fallback price for risk validation".to_string()
|
|
})?;
|
|
|
|
// Check order size limit with overflow protection
|
|
let order_notional = order.quantity * order_price;
|
|
if !order_notional.is_finite() {
|
|
return Err(TradingServiceError::ValidationError {
|
|
message: format!(
|
|
"Order notional overflow: {} * {}",
|
|
order.quantity, order_price
|
|
),
|
|
});
|
|
}
|
|
if order_notional > limits.max_order_size {
|
|
return Ok(false);
|
|
}
|
|
|
|
// Get current position value
|
|
let current_position_value: f64 = sqlx::query_scalar::<_, f64>(
|
|
"SELECT COALESCE(SUM(ABS(market_value)), 0.0) FROM positions WHERE account_id = $1",
|
|
)
|
|
.bind(account_id)
|
|
.fetch_one(&self.pool)
|
|
.await
|
|
.map_err(|e| TradingServiceError::DatabaseError {
|
|
source: Box::new(e),
|
|
})?;
|
|
|
|
// Check position limit with overflow protection
|
|
let order_value = order.quantity * order_price;
|
|
if !order_value.is_finite() {
|
|
return Err(TradingServiceError::ValidationError {
|
|
message: format!("Order value overflow: {} * {}", order.quantity, order_price),
|
|
});
|
|
}
|
|
let new_position_value = current_position_value + order_value;
|
|
if !new_position_value.is_finite() {
|
|
return Err(TradingServiceError::ValidationError {
|
|
message: format!(
|
|
"Position value overflow: {} + {}",
|
|
current_position_value, order_value
|
|
),
|
|
});
|
|
}
|
|
if new_position_value > limits.max_position_limit {
|
|
return Ok(false);
|
|
}
|
|
|
|
Ok(true)
|
|
}
|
|
|
|
async fn calculate_margin_used(&self, account_id: &str) -> TradingServiceResult<f64> {
|
|
let result = sqlx::query_scalar::<_, Option<f64>>(
|
|
r#"
|
|
SELECT SUM(ABS(quantity * average_price) * 0.5)
|
|
FROM positions
|
|
WHERE account_id = $1
|
|
"#,
|
|
)
|
|
.bind(account_id)
|
|
.fetch_optional(&self.pool)
|
|
.await
|
|
.map_err(|e| TradingServiceError::DatabaseError {
|
|
source: Box::new(e),
|
|
})?;
|
|
|
|
// Default margin calculation: 50% of position value
|
|
// In production, this would use asset-specific margin requirements
|
|
// from the risk configuration or asset classification system
|
|
Ok(result.flatten().unwrap_or(0.0))
|
|
}
|
|
}
|
|
|
|
/// Postgre`SQL` implementation of ConfigRepository
|
|
#[derive(Debug, Clone)]
|
|
pub struct PostgresConfigRepository {
|
|
pool: PgPool,
|
|
}
|
|
|
|
impl PostgresConfigRepository {
|
|
pub fn new(pool: PgPool) -> Self {
|
|
Self { pool }
|
|
}
|
|
}
|
|
|
|
#[async_trait]
|
|
impl ConfigRepository for PostgresConfigRepository {
|
|
async fn get_config_f64(&self, category: &str, key: &str) -> TradingServiceResult<Option<f64>> {
|
|
let row = sqlx::query("SELECT value FROM configuration WHERE category = $1 AND key = $2")
|
|
.bind(category)
|
|
.bind(key)
|
|
.fetch_optional(&self.pool)
|
|
.await
|
|
.map_err(|e| TradingServiceError::DatabaseError {
|
|
source: Box::new(e),
|
|
})?;
|
|
|
|
if let Some(row) = row {
|
|
let value_str: String = row.get("value");
|
|
let value: f64 = serde_json::from_str(&value_str).map_err(|e| {
|
|
TradingServiceError::ConfigurationError {
|
|
message: format!("Failed to deserialize config value: {}", e),
|
|
}
|
|
})?;
|
|
Ok(Some(value))
|
|
} else {
|
|
Ok(None)
|
|
}
|
|
}
|
|
|
|
async fn get_config_u64(&self, category: &str, key: &str) -> TradingServiceResult<Option<u64>> {
|
|
let row = sqlx::query("SELECT value FROM configuration WHERE category = $1 AND key = $2")
|
|
.bind(category)
|
|
.bind(key)
|
|
.fetch_optional(&self.pool)
|
|
.await
|
|
.map_err(|e| TradingServiceError::DatabaseError {
|
|
source: Box::new(e),
|
|
})?;
|
|
|
|
if let Some(row) = row {
|
|
let value_str: String = row.get("value");
|
|
let value: u64 = serde_json::from_str(&value_str).map_err(|e| {
|
|
TradingServiceError::ConfigurationError {
|
|
message: format!("Failed to deserialize config value: {}", e),
|
|
}
|
|
})?;
|
|
Ok(Some(value))
|
|
} else {
|
|
Ok(None)
|
|
}
|
|
}
|
|
|
|
async fn get_config_string(
|
|
&self,
|
|
category: &str,
|
|
key: &str,
|
|
) -> TradingServiceResult<Option<String>> {
|
|
let row = sqlx::query("SELECT value FROM configuration WHERE category = $1 AND key = $2")
|
|
.bind(category)
|
|
.bind(key)
|
|
.fetch_optional(&self.pool)
|
|
.await
|
|
.map_err(|e| TradingServiceError::DatabaseError {
|
|
source: Box::new(e),
|
|
})?;
|
|
|
|
if let Some(row) = row {
|
|
let value_str: String = row.get("value");
|
|
let value: String = serde_json::from_str(&value_str).map_err(|e| {
|
|
TradingServiceError::ConfigurationError {
|
|
message: format!("Failed to deserialize config value: {}", e),
|
|
}
|
|
})?;
|
|
Ok(Some(value))
|
|
} else {
|
|
Ok(None)
|
|
}
|
|
}
|
|
|
|
async fn set_config<T>(&self, category: &str, key: &str, value: &T) -> TradingServiceResult<()>
|
|
where
|
|
T: serde::Serialize + Send + Sync,
|
|
{
|
|
let value_json =
|
|
serde_json::to_string(value).map_err(|e| TradingServiceError::ConfigurationError {
|
|
message: format!("Failed to serialize config value: {}", e),
|
|
})?;
|
|
|
|
sqlx::query(
|
|
r#"
|
|
INSERT INTO configuration (category, key, value, updated_at)
|
|
VALUES ($1, $2, $3, NOW())
|
|
ON CONFLICT (category, key) DO UPDATE SET
|
|
value = EXCLUDED.value,
|
|
updated_at = EXCLUDED.updated_at
|
|
"#,
|
|
)
|
|
.bind(category)
|
|
.bind(key)
|
|
.bind(value_json)
|
|
.execute(&self.pool)
|
|
.await
|
|
.map_err(|e| TradingServiceError::DatabaseError {
|
|
source: Box::new(e),
|
|
})?;
|
|
|
|
Ok(())
|
|
}
|
|
async fn get_secret(&self, key: &str) -> TradingServiceResult<Option<String>> {
|
|
let row = sqlx::query("SELECT value FROM secrets WHERE key = $1")
|
|
.bind(key)
|
|
.fetch_optional(&self.pool)
|
|
.await
|
|
.map_err(|e| TradingServiceError::DatabaseError {
|
|
source: Box::new(e),
|
|
})?;
|
|
|
|
Ok(row.map(|r| r.get("value")))
|
|
}
|
|
|
|
async fn set_secret(&self, key: &str, value: &str) -> TradingServiceResult<()> {
|
|
sqlx::query(
|
|
r#"
|
|
INSERT INTO secrets (key, value, updated_at)
|
|
VALUES ($1, $2, NOW())
|
|
ON CONFLICT (key) DO UPDATE SET
|
|
value = EXCLUDED.value,
|
|
updated_at = EXCLUDED.updated_at
|
|
"#,
|
|
)
|
|
.bind(key)
|
|
.bind(value)
|
|
.execute(&self.pool)
|
|
.await
|
|
.map_err(|e| TradingServiceError::DatabaseError {
|
|
source: Box::new(e),
|
|
})?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
async fn subscribe_to_changes(&self) -> TradingServiceResult<ConfigChangeReceiver> {
|
|
let (_tx, rx) = tokio::sync::broadcast::channel(1000);
|
|
|
|
// In production, this would use PostgreSQL LISTEN/NOTIFY
|
|
// For now, return a channel that can be used for config change notifications
|
|
tokio::spawn(async move {
|
|
// Placeholder - would implement PostgreSQL LISTEN here
|
|
});
|
|
|
|
Ok(rx)
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// Mock Implementations for Testing
|
|
// =============================================================================
|
|
|
|
#[cfg(test)]
|
|
#[allow(dead_code)]
|
|
mod mock_repositories {
|
|
use super::*;
|
|
|
|
/// Mock implementation of TradingRepository for testing
|
|
#[derive(Debug, Clone, Default)]
|
|
pub struct MockTradingRepository;
|
|
|
|
impl MockTradingRepository {
|
|
pub fn new() -> Self {
|
|
Self
|
|
}
|
|
}
|
|
|
|
#[async_trait]
|
|
impl TradingRepository for MockTradingRepository {
|
|
async fn store_order(&self, _order: &TradingOrder) -> TradingServiceResult<String> {
|
|
Ok(uuid::Uuid::new_v4().to_string())
|
|
}
|
|
|
|
async fn update_order_status(
|
|
&self,
|
|
_order_id: &str,
|
|
_status: common::types::OrderStatus,
|
|
) -> TradingServiceResult<()> {
|
|
Ok(())
|
|
}
|
|
|
|
async fn get_order(&self, _order_id: &str) -> TradingServiceResult<Option<TradingOrder>> {
|
|
Ok(None)
|
|
}
|
|
|
|
async fn get_orders_for_account(
|
|
&self,
|
|
_account_id: &str,
|
|
) -> TradingServiceResult<Vec<TradingOrder>> {
|
|
Ok(Vec::new())
|
|
}
|
|
|
|
async fn store_execution(
|
|
&self,
|
|
_execution: &crate::repositories::ExecutionEvent,
|
|
) -> TradingServiceResult<()> {
|
|
Ok(())
|
|
}
|
|
|
|
async fn get_execution_history(
|
|
&self,
|
|
_request: &GetExecutionHistoryRequest,
|
|
) -> TradingServiceResult<Vec<crate::repositories::ExecutionEvent>> {
|
|
Ok(Vec::new())
|
|
}
|
|
async fn store_position(&self, _position: &TradingPosition) -> TradingServiceResult<()> {
|
|
Ok(())
|
|
}
|
|
|
|
async fn get_positions(
|
|
&self,
|
|
_account_id: Option<&str>,
|
|
_symbol: Option<&str>,
|
|
) -> TradingServiceResult<Vec<TradingPosition>> {
|
|
Ok(Vec::new())
|
|
}
|
|
|
|
async fn get_portfolio_summary(
|
|
&self,
|
|
account_id: &str,
|
|
) -> TradingServiceResult<PortfolioSummary> {
|
|
Ok(PortfolioSummary {
|
|
account_id: account_id.to_string(),
|
|
total_value: 0.0,
|
|
cash_balance: 0.0,
|
|
positions_value: 0.0,
|
|
unrealized_pnl: 0.0,
|
|
realized_pnl: 0.0,
|
|
})
|
|
}
|
|
|
|
async fn get_realized_pnl(
|
|
&self,
|
|
_account_id: &str,
|
|
_symbol: Option<&str>,
|
|
) -> TradingServiceResult<f64> {
|
|
Ok(0.0)
|
|
}
|
|
|
|
async fn get_day_pnl(&self, _account_id: &str) -> TradingServiceResult<f64> {
|
|
Ok(0.0)
|
|
}
|
|
}
|
|
|
|
/// Mock implementation of MarketDataRepository for testing
|
|
#[derive(Debug, Clone, Default)]
|
|
pub struct MockMarketDataRepository;
|
|
|
|
impl MockMarketDataRepository {
|
|
pub fn new() -> Self {
|
|
Self
|
|
}
|
|
}
|
|
|
|
#[async_trait]
|
|
impl MarketDataRepository for MockMarketDataRepository {
|
|
async fn store_market_tick(&self, _tick: &MarketTick) -> TradingServiceResult<()> {
|
|
Ok(())
|
|
}
|
|
|
|
async fn get_order_book(
|
|
&self,
|
|
symbol: &str,
|
|
_depth: i32,
|
|
) -> TradingServiceResult<crate::repositories::OrderBook> {
|
|
Ok(crate::repositories::OrderBook {
|
|
symbol: symbol.to_string(),
|
|
bids: Vec::new(),
|
|
asks: Vec::new(),
|
|
timestamp: chrono::Utc::now().timestamp(),
|
|
})
|
|
}
|
|
|
|
async fn store_order_book(
|
|
&self,
|
|
_symbol: &str,
|
|
_order_book: &crate::repositories::OrderBook,
|
|
) -> TradingServiceResult<()> {
|
|
Ok(())
|
|
}
|
|
async fn get_latest_prices(
|
|
&self,
|
|
_symbols: &[String],
|
|
) -> TradingServiceResult<Vec<MarketTick>> {
|
|
Ok(Vec::new())
|
|
}
|
|
|
|
async fn store_market_event(
|
|
&self,
|
|
_event: &common::MarketDataEvent,
|
|
) -> TradingServiceResult<()> {
|
|
Ok(())
|
|
}
|
|
|
|
async fn get_historical_data(
|
|
&self,
|
|
_symbol: &str,
|
|
_from: i64,
|
|
_to: i64,
|
|
) -> TradingServiceResult<Vec<MarketTick>> {
|
|
Ok(Vec::new())
|
|
}
|
|
|
|
async fn get_order_book_level_count(
|
|
&self,
|
|
_symbol: &str,
|
|
_price: f64,
|
|
_side: common::OrderSide,
|
|
) -> TradingServiceResult<i32> {
|
|
Ok(1)
|
|
}
|
|
}
|
|
|
|
/// Mock implementation of RiskRepository for testing
|
|
#[derive(Debug, Clone, Default)]
|
|
pub struct MockRiskRepository;
|
|
|
|
impl MockRiskRepository {
|
|
pub fn new() -> Self {
|
|
Self
|
|
}
|
|
}
|
|
|
|
#[async_trait]
|
|
impl RiskRepository for MockRiskRepository {
|
|
async fn store_var_calculation(
|
|
&self,
|
|
_calculation: &VarCalculation,
|
|
) -> TradingServiceResult<()> {
|
|
Ok(())
|
|
}
|
|
|
|
async fn get_risk_limits(&self, account_id: &str) -> TradingServiceResult<RiskLimits> {
|
|
Ok(RiskLimits {
|
|
account_id: account_id.to_string(),
|
|
max_order_size: 1000000.0,
|
|
max_position_limit: 10000000.0,
|
|
max_drawdown_limit: 0.10,
|
|
daily_loss_limit: Some(50000.0),
|
|
})
|
|
}
|
|
|
|
async fn update_risk_limits(
|
|
&self,
|
|
_account_id: &str,
|
|
_limits: &RiskLimits,
|
|
) -> TradingServiceResult<()> {
|
|
Ok(())
|
|
}
|
|
|
|
async fn store_risk_alert(&self, _alert: &RiskAlert) -> TradingServiceResult<()> {
|
|
Ok(())
|
|
}
|
|
|
|
async fn get_risk_metrics(&self, account_id: &str) -> TradingServiceResult<RiskMetrics> {
|
|
Ok(RiskMetrics {
|
|
account_id: account_id.to_string(),
|
|
current_var: 0.0,
|
|
current_drawdown: 0.0,
|
|
position_concentration: 0.0,
|
|
leverage_ratio: 1.0,
|
|
})
|
|
}
|
|
|
|
async fn store_position_risk(
|
|
&self,
|
|
_account_id: &str,
|
|
_symbol: &str,
|
|
_risk: &PositionRisk,
|
|
) -> TradingServiceResult<()> {
|
|
Ok(())
|
|
}
|
|
|
|
async fn validate_order_risk(
|
|
&self,
|
|
_account_id: &str,
|
|
_order: &OrderRequest,
|
|
) -> TradingServiceResult<bool> {
|
|
Ok(true)
|
|
}
|
|
|
|
async fn calculate_margin_used(&self, _account_id: &str) -> TradingServiceResult<f64> {
|
|
Ok(0.0)
|
|
}
|
|
}
|
|
}
|