Files
foxhunt/trading-data/src/positions.rs
jgrusewski 1ece987396 chore(clippy): add deny(unwrap_used) to 4 low-violation crates and fix 13 violations
Add #![deny(clippy::unwrap_used, clippy::expect_used)] to database, ml-data,
trading-data, and broker_gateway_service crates, fixing all violations:

- database/src/transaction.rs: replace 7x .expect("Transaction already consumed")
  with .ok_or_else(|| DatabaseError::Transaction) and 2x .unwrap() on take()
  in commit/rollback with safe .ok_or_else() variants
- ml-data/src/performance.rs: replace .last().unwrap() and .first().unwrap()
  with if-let destructuring pattern
- trading-data/src/positions.rs: replace 3x write!().unwrap() with let _ = write!()
  and Decimal::from_str_exact("0.02").unwrap() with Decimal::new(2, 2)
- trading-data/src/executions.rs: replace 3x write!().unwrap() with let _ = write!(),
  and 3x .expect() on and_hms_opt(0,0,0) with .unwrap_or_default()
- broker_gateway_service/src/main.rs: replace encode().unwrap() with if-let,
  and from_utf8().unwrap() with .unwrap_or_else()
- Add #[allow(clippy::unwrap_used)] to test modules in all affected crates

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 23:59:10 +01:00

918 lines
31 KiB
Rust

//! Position repository implementation
//!
//! This module provides repository pattern implementation for trading positions
//! with `PostgreSQL` backing. It includes real-time position tracking, P&L calculations,
//! and position management operations for high-frequency trading.
use async_trait::async_trait;
use chrono::{DateTime, Utc};
use rust_decimal::Decimal;
// Removed direct rust_decimal import - using common::Decimal via common crate
use sqlx::{Pool, Postgres, Row};
use uuid::Uuid;
use crate::{Repository, RepositoryError, Result};
// Import Position directly from common
use common::Position;
/// Filter criteria for position queries
#[derive(Debug, Default, Clone)]
pub struct PositionFilter {
/// Filter by symbol
pub symbol: Option<String>,
/// Filter positions with quantity greater than this value
pub min_quantity: Option<Decimal>,
/// Filter positions with quantity less than this value
pub max_quantity: Option<Decimal>,
/// Filter by position type (long/short/flat)
pub position_type: Option<PositionType>,
/// Filter positions with unrealized P&L above this threshold
pub min_unrealized_pnl: Option<Decimal>,
/// Filter positions with unrealized P&L below this threshold
pub max_unrealized_pnl: Option<Decimal>,
/// Filter positions created after this timestamp
pub created_after: Option<DateTime<Utc>>,
/// Filter positions created before this timestamp
pub created_before: Option<DateTime<Utc>>,
/// Include only positions with current price data
pub has_current_price: Option<bool>,
/// Limit number of results
pub limit: Option<i64>,
/// Offset for pagination
pub offset: Option<i64>,
}
/// Position type for filtering and categorization
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PositionType {
/// Long position (quantity > 0)
Long,
/// Short position (quantity < 0)
Short,
/// Flat position (quantity = 0)
Flat,
}
impl PositionFilter {
/// Create a new empty filter
#[must_use]
pub fn new() -> Self {
Self::default()
}
/// Filter by symbol
#[must_use]
pub fn symbol<S: Into<String>>(mut self, symbol: S) -> Self {
self.symbol = Some(symbol.into());
self
}
/// Filter by position type
#[must_use]
pub fn position_type(mut self, position_type: PositionType) -> Self {
self.position_type = Some(position_type);
self
}
/// Filter by quantity range
#[must_use]
pub fn quantity_range(mut self, min: Option<Decimal>, max: Option<Decimal>) -> Self {
self.min_quantity = min;
self.max_quantity = max;
self
}
/// Filter by P&L range
#[must_use]
pub fn pnl_range(mut self, min: Option<Decimal>, max: Option<Decimal>) -> Self {
self.min_unrealized_pnl = min;
self.max_unrealized_pnl = max;
self
}
/// Filter by date range
#[must_use]
pub fn created_between(mut self, start: DateTime<Utc>, end: DateTime<Utc>) -> Self {
self.created_after = Some(start);
self.created_before = Some(end);
self
}
/// Filter only positions with current price data
#[must_use]
pub fn with_current_price(mut self) -> Self {
self.has_current_price = Some(true);
self
}
/// Limit results
#[must_use]
pub fn limit(mut self, limit: i64) -> Self {
self.limit = Some(limit);
self
}
/// Set offset for pagination
#[must_use]
pub fn offset(mut self, offset: i64) -> Self {
self.offset = Some(offset);
self
}
}
/// Position repository trait defining available operations
#[async_trait]
pub trait PositionRepository: Repository<Position, Uuid> {
/// Find positions matching the filter criteria
async fn find_by_filter(&self, filter: &PositionFilter) -> Result<Vec<Position>>;
/// Find position by symbol (there should typically be only one per symbol)
async fn find_by_symbol(&self, symbol: &str) -> Result<Option<Position>>;
/// Find all positions with non-zero quantity
async fn find_active_positions(&self) -> Result<Vec<Position>>;
/// Find positions by type (long/short/flat)
async fn find_by_type(&self, position_type: PositionType) -> Result<Vec<Position>>;
/// Update position quantity and recalculate averages
async fn update_position(
&self,
symbol: &str,
quantity_delta: Decimal,
price: Decimal,
) -> Result<Position>;
/// Update position's current price and recalculate unrealized P&L
async fn update_market_price(&self, symbol: &str, current_price: Decimal) -> Result<()>;
/// Batch update market prices for multiple positions
async fn batch_update_prices(
&self,
price_updates: &[(String, Decimal)],
) -> Result<Vec<Position>>;
/// Close position (set quantity to zero, realize P&L)
async fn close_position(&self, symbol: &str, closing_price: Decimal) -> Result<Position>;
/// Get position statistics
async fn get_position_stats(&self) -> Result<PositionStats>;
/// Get positions with high risk (based on P&L thresholds)
async fn find_high_risk_positions(&self, pnl_threshold: Decimal) -> Result<Vec<Position>>;
/// Calculate total portfolio P&L
async fn calculate_total_pnl(&self) -> Result<PortfolioPnL>;
/// Get positions ready for margin calls
async fn find_margin_call_positions(&self, margin_threshold: Decimal) -> Result<Vec<Position>>;
}
/// Position statistics for portfolio analysis
#[derive(Debug, Clone)]
pub struct PositionStats {
/// Total number of positions across all symbols
pub total_positions: i64,
/// Number of long positions (quantity > 0)
pub long_positions: i64,
/// Number of short positions (quantity < 0)
pub short_positions: i64,
/// Number of flat positions (quantity = 0)
pub flat_positions: i64,
/// Total notional value of all positions
pub total_notional_value: Decimal,
/// Total unrealized profit and loss
pub total_unrealized_pnl: Decimal,
/// Total realized profit and loss
pub total_realized_pnl: Decimal,
/// Largest position by absolute quantity
pub largest_position: Option<Decimal>,
/// Symbol with the highest total P&L
pub most_profitable_symbol: Option<String>,
/// Symbol with the lowest total P&L
pub least_profitable_symbol: Option<String>,
}
/// Portfolio profit and loss summary
#[derive(Debug, Clone)]
pub struct PortfolioPnL {
/// Total unrealized profit and loss across all positions
pub total_unrealized_pnl: Decimal,
/// Total realized profit and loss from closed positions
pub total_realized_pnl: Decimal,
/// Combined total P&L (realized + unrealized)
pub total_pnl: Decimal,
/// Total notional value of all positions
pub total_notional_value: Decimal,
/// Total margin requirement for all positions
pub total_margin_requirement: Decimal,
/// Return on investment as a percentage
pub roi_percentage: Decimal,
/// Number of active positions
pub position_count: i64,
}
/// `PostgreSQL` implementation of `PositionRepository`
pub struct PostgresPositionRepository {
pool: Pool<Postgres>,
}
impl PostgresPositionRepository {
/// Create a new `PostgreSQL` position repository
#[must_use]
pub fn new(pool: Pool<Postgres>) -> Self {
Self { pool }
}
/// Helper method to convert `PositionType` to SQL condition
fn position_type_to_sql_condition(position_type: PositionType) -> &'static str {
match position_type {
PositionType::Long => "quantity > 0",
PositionType::Short => "quantity < 0",
PositionType::Flat => "quantity = 0",
}
}
}
#[async_trait]
impl Repository<Position, Uuid> for PostgresPositionRepository {
async fn find_by_id(&self, id: &Uuid) -> Result<Option<Position>> {
let query = r"
SELECT id, symbol, quantity, avg_price, unrealized_pnl, realized_pnl,
created_at, updated_at, current_price, notional_value, margin_requirement
FROM positions WHERE id = $1
";
let row = sqlx::query(query)
.bind(id)
.fetch_optional(&self.pool)
.await?;
match row {
Some(row) => {
let position = Position {
id: row.get("id"),
symbol: row.get("symbol"),
quantity: row.get("quantity"),
avg_price: row.get("avg_price"),
avg_cost: row.get("avg_price"), // alias for avg_price
basis: row.get("avg_price"), // cost basis, using avg_price
average_price: row.get("avg_price"), // alias for avg_price
market_value: row.get("notional_value"), // using notional_value as market_value
unrealized_pnl: row.get("unrealized_pnl"),
realized_pnl: row.get("realized_pnl"),
created_at: row.get("created_at"),
updated_at: row.get("updated_at"),
last_updated: row.get("updated_at"), // alias for updated_at
current_price: row.get("current_price"),
notional_value: row.get("notional_value"),
margin_requirement: row.get("margin_requirement"),
};
Ok(Some(position))
},
None => Ok(None),
}
}
async fn save(&self, position: &Position) -> Result<Position> {
let query = r"
INSERT INTO positions (id, symbol, quantity, avg_price, unrealized_pnl, realized_pnl,
created_at, updated_at, current_price, notional_value, margin_requirement)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
ON CONFLICT (id) DO UPDATE SET
quantity = EXCLUDED.quantity,
avg_price = EXCLUDED.avg_price,
unrealized_pnl = EXCLUDED.unrealized_pnl,
realized_pnl = EXCLUDED.realized_pnl,
updated_at = EXCLUDED.updated_at,
current_price = EXCLUDED.current_price,
notional_value = EXCLUDED.notional_value,
margin_requirement = EXCLUDED.margin_requirement
RETURNING *
";
let row = sqlx::query(query)
.bind(position.id)
.bind(&position.symbol)
.bind(position.quantity)
.bind(position.avg_price)
.bind(position.unrealized_pnl)
.bind(position.realized_pnl)
.bind(position.created_at)
.bind(position.updated_at)
.bind(position.current_price)
.bind(position.notional_value)
.bind(position.margin_requirement)
.fetch_one(&self.pool)
.await?;
Ok(Position {
id: row.get("id"),
symbol: row.get("symbol"),
quantity: row.get("quantity"),
avg_price: row.get("avg_price"),
avg_cost: row.get("avg_price"), // alias for avg_price
basis: row.get("avg_price"), // cost basis, using avg_price
average_price: row.get("avg_price"), // alias for avg_price
market_value: row.get("notional_value"), // using notional_value as market_value
unrealized_pnl: row.get("unrealized_pnl"),
realized_pnl: row.get("realized_pnl"),
created_at: row.get("created_at"),
updated_at: row.get("updated_at"),
last_updated: row.get("updated_at"), // alias for updated_at
current_price: row.get("current_price"),
notional_value: row.get("notional_value"),
margin_requirement: row.get("margin_requirement"),
})
}
async fn delete(&self, id: &Uuid) -> Result<bool> {
let result = sqlx::query("DELETE FROM positions WHERE id = $1")
.bind(id)
.execute(&self.pool)
.await?;
Ok(result.rows_affected() > 0)
}
async fn exists(&self, id: &Uuid) -> Result<bool> {
let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM positions WHERE id = $1")
.bind(id)
.fetch_one(&self.pool)
.await?;
Ok(count > 0)
}
}
#[async_trait]
impl PositionRepository for PostgresPositionRepository {
async fn find_by_filter(&self, filter: &PositionFilter) -> Result<Vec<Position>> {
use std::fmt::Write;
let mut conditions = Vec::new();
let mut param_count = 0;
let mut query = r"
SELECT id, symbol, quantity, avg_price, unrealized_pnl, realized_pnl,
created_at, updated_at, current_price, notional_value, margin_requirement
FROM positions
"
.to_string();
// Build WHERE clause dynamically based on filter
if filter.symbol.is_some() {
param_count += 1;
conditions.push(format!("symbol = ${param_count}"));
}
if let Some(pos_type) = filter.position_type {
let condition = Self::position_type_to_sql_condition(pos_type);
conditions.push(condition.to_string());
}
if filter.min_quantity.is_some() {
param_count += 1;
conditions.push(format!("ABS(quantity) >= ${param_count}"));
}
if filter.max_quantity.is_some() {
param_count += 1;
conditions.push(format!("ABS(quantity) <= ${param_count}"));
}
if filter.min_unrealized_pnl.is_some() {
param_count += 1;
conditions.push(format!("unrealized_pnl >= ${param_count}"));
}
if filter.max_unrealized_pnl.is_some() {
param_count += 1;
conditions.push(format!("unrealized_pnl <= ${param_count}"));
}
if filter.created_after.is_some() {
param_count += 1;
conditions.push(format!("created_at >= ${param_count}"));
}
if filter.created_before.is_some() {
param_count += 1;
conditions.push(format!("created_at <= ${param_count}"));
}
if let Some(has_current_price) = filter.has_current_price {
if has_current_price {
conditions.push("current_price IS NOT NULL".to_owned());
} else {
conditions.push("current_price IS NULL".to_owned());
}
}
if !conditions.is_empty() {
// Writing to a String never fails
let _ = write!(query, " WHERE {}", conditions.join(" AND "));
}
query.push_str(" ORDER BY updated_at DESC");
if let Some(_limit) = filter.limit {
param_count += 1;
// Writing to a String never fails
let _ = write!(query, " LIMIT ${param_count}");
}
if let Some(_offset) = filter.offset {
param_count += 1;
// Writing to a String never fails
let _ = write!(query, " OFFSET ${param_count}");
}
// Execute query with parameters (simplified version)
let positions = sqlx::query_as::<_, Position>(&query)
.fetch_all(&self.pool)
.await?;
Ok(positions)
}
async fn find_by_symbol(&self, symbol: &str) -> Result<Option<Position>> {
let position = sqlx::query_as::<_, Position>(
r"
SELECT id, symbol, quantity, avg_price, unrealized_pnl, realized_pnl,
created_at, updated_at, current_price, notional_value, margin_requirement
FROM positions WHERE symbol = $1 LIMIT 1
",
)
.bind(symbol)
.fetch_optional(&self.pool)
.await?;
Ok(position)
}
async fn find_active_positions(&self) -> Result<Vec<Position>> {
let positions = sqlx::query_as::<_, Position>(
r"
SELECT id, symbol, quantity, avg_price, unrealized_pnl, realized_pnl,
created_at, updated_at, current_price, notional_value, margin_requirement
FROM positions WHERE quantity != 0 ORDER BY ABS(quantity) DESC
",
)
.fetch_all(&self.pool)
.await?;
Ok(positions)
}
async fn find_by_type(&self, position_type: PositionType) -> Result<Vec<Position>> {
let condition = Self::position_type_to_sql_condition(position_type);
let query = format!(
r"
SELECT id, symbol, quantity, avg_price, unrealized_pnl, realized_pnl,
created_at, updated_at, current_price, notional_value, margin_requirement
FROM positions WHERE {condition} ORDER BY ABS(quantity) DESC
"
);
let positions = sqlx::query_as::<_, Position>(&query)
.fetch_all(&self.pool)
.await?;
Ok(positions)
}
async fn update_position(
&self,
symbol: &str,
quantity_delta: Decimal,
price: Decimal,
) -> Result<Position> {
let mut tx = self.pool.begin().await?;
// First, try to get existing position
let existing_position = sqlx::query_as::<_, Position>(
r"
SELECT id, symbol, quantity, avg_price, unrealized_pnl, realized_pnl,
created_at, updated_at, current_price, notional_value, margin_requirement
FROM positions WHERE symbol = $1 FOR UPDATE
",
)
.bind(symbol)
.fetch_optional(&mut *tx)
.await?;
let updated_position = if let Some(mut position) = existing_position {
// Update existing position
let old_quantity = position.quantity;
let new_quantity = old_quantity + quantity_delta;
// Calculate new average price
let new_avg_price = if new_quantity.is_zero() {
position.avg_price // Keep old average when closing
} else if old_quantity.is_zero() {
price // New position
} else if (old_quantity > Decimal::ZERO) == (quantity_delta > Decimal::ZERO) {
// Adding to existing position (same side)
let total_cost = old_quantity * position.avg_price + quantity_delta * price;
total_cost / new_quantity
} else {
// Reducing position or switching sides
if new_quantity.abs() < old_quantity.abs() {
position.avg_price // Keep average when reducing
} else {
price // New average when switching sides
}
};
position.quantity = new_quantity;
position.avg_price = new_avg_price;
position.notional_value = new_quantity.abs() * new_avg_price;
position.margin_requirement =
position.notional_value * Decimal::new(2, 2); // 0.02
position.updated_at = Utc::now();
// Update in database
sqlx::query(
r"
UPDATE positions SET
quantity = $1,
avg_price = $2,
notional_value = $3,
margin_requirement = $4,
updated_at = $5
WHERE symbol = $6
",
)
.bind(position.quantity)
.bind(position.avg_price)
.bind(position.notional_value)
.bind(position.margin_requirement)
.bind(position.updated_at)
.bind(symbol)
.execute(&mut *tx)
.await?;
position
} else {
// Create new position
let position = Position::new(symbol.to_string(), quantity_delta, price);
sqlx::query(
r"
INSERT INTO positions (id, symbol, quantity, avg_price, unrealized_pnl, realized_pnl,
created_at, updated_at, current_price, notional_value, margin_requirement)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
"
)
.bind(position.id)
.bind(&position.symbol)
.bind(position.quantity)
.bind(position.avg_price)
.bind(position.unrealized_pnl)
.bind(position.realized_pnl)
.bind(position.created_at)
.bind(position.updated_at)
.bind(position.current_price)
.bind(position.notional_value)
.bind(position.margin_requirement)
.execute(&mut *tx)
.await?;
position
};
tx.commit().await?;
Ok(updated_position)
}
async fn update_market_price(&self, symbol: &str, current_price: Decimal) -> Result<()> {
sqlx::query(
r"
UPDATE positions SET
current_price = $1,
unrealized_pnl = CASE
WHEN quantity > 0 THEN quantity * ($1 - avg_price)
WHEN quantity < 0 THEN quantity * (avg_price - $1)
ELSE 0
END,
updated_at = $2
WHERE symbol = $3
",
)
.bind(current_price)
.bind(Utc::now())
.bind(symbol)
.execute(&self.pool)
.await?;
Ok(())
}
async fn batch_update_prices(
&self,
price_updates: &[(String, Decimal)],
) -> Result<Vec<Position>> {
if price_updates.is_empty() {
return Ok(Vec::new());
}
let mut tx = self.pool.begin().await?;
let mut updated_positions = Vec::new();
for (symbol, price) in price_updates {
// Update the position
sqlx::query(
r"
UPDATE positions SET
current_price = $1,
unrealized_pnl = CASE
WHEN quantity > 0 THEN quantity * ($1 - avg_price)
WHEN quantity < 0 THEN quantity * (avg_price - $1)
ELSE 0
END,
updated_at = $2
WHERE symbol = $3
",
)
.bind(price)
.bind(Utc::now())
.bind(symbol)
.execute(&mut *tx)
.await?;
// Fetch the updated position
if let Some(position) = sqlx::query_as::<_, Position>(
r"
SELECT id, symbol, quantity, avg_price, unrealized_pnl, realized_pnl,
created_at, updated_at, current_price, notional_value, margin_requirement
FROM positions WHERE symbol = $1
",
)
.bind(symbol)
.fetch_optional(&mut *tx)
.await?
{
updated_positions.push(position);
}
}
tx.commit().await?;
Ok(updated_positions)
}
async fn close_position(&self, symbol: &str, closing_price: Decimal) -> Result<Position> {
let mut tx = self.pool.begin().await?;
// Get the current position
let mut position = sqlx::query_as::<_, Position>(
r"
SELECT id, symbol, quantity, avg_price, unrealized_pnl, realized_pnl,
created_at, updated_at, current_price, notional_value, margin_requirement
FROM positions WHERE symbol = $1 FOR UPDATE
",
)
.bind(symbol)
.fetch_optional(&mut *tx)
.await?
.ok_or_else(|| {
RepositoryError::NotFound(format!("Position not found for symbol: {symbol}"))
})?;
// Calculate realized P&L
let realized_pnl_delta = if position.is_long() {
position.quantity * (closing_price - position.avg_price)
} else {
position.quantity * (position.avg_price - closing_price)
};
// Update position to closed state
position.realized_pnl += realized_pnl_delta;
position.quantity = Decimal::ZERO;
position.unrealized_pnl = Decimal::ZERO;
position.current_price = Some(closing_price);
position.notional_value = Decimal::ZERO;
position.margin_requirement = Decimal::ZERO;
position.updated_at = Utc::now();
sqlx::query(
r"
UPDATE positions SET
quantity = 0,
realized_pnl = $1,
unrealized_pnl = 0,
current_price = $2,
notional_value = 0,
margin_requirement = 0,
updated_at = $3
WHERE symbol = $4
",
)
.bind(position.realized_pnl)
.bind(closing_price)
.bind(position.updated_at)
.bind(symbol)
.execute(&mut *tx)
.await?;
tx.commit().await?;
Ok(position)
}
async fn get_position_stats(&self) -> Result<PositionStats> {
let row = sqlx::query(
r"
SELECT
COUNT(*) as total_positions,
COUNT(CASE WHEN quantity > 0 THEN 1 END) as long_positions,
COUNT(CASE WHEN quantity < 0 THEN 1 END) as short_positions,
COUNT(CASE WHEN quantity = 0 THEN 1 END) as flat_positions,
COALESCE(SUM(ABS(notional_value)), 0) as total_notional_value,
COALESCE(SUM(unrealized_pnl), 0) as total_unrealized_pnl,
COALESCE(SUM(realized_pnl), 0) as total_realized_pnl,
MAX(ABS(quantity)) as largest_position
FROM positions
",
)
.fetch_one(&self.pool)
.await?;
// Get most and least profitable symbols
let profitable_row = sqlx::query(
r"
SELECT symbol, (unrealized_pnl + realized_pnl) as total_pnl
FROM positions
WHERE (unrealized_pnl + realized_pnl) != 0
ORDER BY total_pnl DESC
LIMIT 1
",
)
.fetch_optional(&self.pool)
.await?;
let unprofitable_row = sqlx::query(
r"
SELECT symbol, (unrealized_pnl + realized_pnl) as total_pnl
FROM positions
WHERE (unrealized_pnl + realized_pnl) != 0
ORDER BY total_pnl ASC
LIMIT 1
",
)
.fetch_optional(&self.pool)
.await?;
Ok(PositionStats {
total_positions: row.get("total_positions"),
long_positions: row.get("long_positions"),
short_positions: row.get("short_positions"),
flat_positions: row.get("flat_positions"),
total_notional_value: row.get("total_notional_value"),
total_unrealized_pnl: row.get("total_unrealized_pnl"),
total_realized_pnl: row.get("total_realized_pnl"),
largest_position: row.get("largest_position"),
most_profitable_symbol: profitable_row.map(|r| r.get("symbol")),
least_profitable_symbol: unprofitable_row.map(|r| r.get("symbol")),
})
}
async fn find_high_risk_positions(&self, pnl_threshold: Decimal) -> Result<Vec<Position>> {
let positions = sqlx::query_as::<_, Position>(
r"
SELECT id, symbol, quantity, avg_price, unrealized_pnl, realized_pnl,
created_at, updated_at, current_price, notional_value, margin_requirement
FROM positions
WHERE unrealized_pnl <= $1 AND quantity != 0
ORDER BY unrealized_pnl ASC
",
)
.bind(pnl_threshold)
.fetch_all(&self.pool)
.await?;
Ok(positions)
}
async fn calculate_total_pnl(&self) -> Result<PortfolioPnL> {
let row = sqlx::query(
r"
SELECT
COALESCE(SUM(unrealized_pnl), 0) as total_unrealized_pnl,
COALESCE(SUM(realized_pnl), 0) as total_realized_pnl,
COALESCE(SUM(ABS(notional_value)), 0) as total_notional_value,
COALESCE(SUM(margin_requirement), 0) as total_margin_requirement,
COUNT(CASE WHEN quantity != 0 THEN 1 END) as position_count
FROM positions
",
)
.fetch_one(&self.pool)
.await?;
let total_unrealized_pnl: Decimal = row.get("total_unrealized_pnl");
let total_realized_pnl: Decimal = row.get("total_realized_pnl");
let total_notional_value: Decimal = row.get("total_notional_value");
let total_margin_requirement: Decimal = row.get("total_margin_requirement");
let position_count: i64 = row.get("position_count");
let total_pnl = total_unrealized_pnl + total_realized_pnl;
let roi_percentage = if total_notional_value.is_zero() {
Decimal::ZERO
} else {
total_pnl / total_notional_value * Decimal::from(100)
};
Ok(PortfolioPnL {
total_unrealized_pnl,
total_realized_pnl,
total_pnl,
total_notional_value,
total_margin_requirement,
roi_percentage,
position_count,
})
}
async fn find_margin_call_positions(&self, margin_threshold: Decimal) -> Result<Vec<Position>> {
let positions = sqlx::query_as::<_, Position>(
r"
SELECT id, symbol, quantity, avg_price, unrealized_pnl, realized_pnl,
created_at, updated_at, current_price, notional_value, margin_requirement
FROM positions
WHERE quantity != 0 AND (
(unrealized_pnl < 0 AND ABS(unrealized_pnl) >= margin_requirement * $1) OR
(margin_requirement > 0 AND (notional_value + unrealized_pnl) / margin_requirement < $1)
)
ORDER BY unrealized_pnl ASC
"
)
.bind(margin_threshold)
.fetch_all(&self.pool)
.await?;
Ok(positions)
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
use rust_decimal_macros::dec;
#[test]
fn test_position_filter_builder() {
let filter = PositionFilter::new()
.symbol("EURUSD")
.position_type(PositionType::Long)
.limit(25);
assert_eq!(filter.symbol.as_ref().unwrap(), "EURUSD");
assert_eq!(filter.position_type.unwrap(), PositionType::Long);
assert_eq!(filter.limit.unwrap(), 25);
}
#[test]
fn test_position_type_sql_condition() {
assert_eq!(
PostgresPositionRepository::position_type_to_sql_condition(PositionType::Long),
"quantity > 0"
);
assert_eq!(
PostgresPositionRepository::position_type_to_sql_condition(PositionType::Short),
"quantity < 0"
);
assert_eq!(
PostgresPositionRepository::position_type_to_sql_condition(PositionType::Flat),
"quantity = 0"
);
}
#[test]
fn test_portfolio_pnl() {
let portfolio_pnl = PortfolioPnL {
total_unrealized_pnl: dec!(5000),
total_realized_pnl: dec!(2000),
total_pnl: dec!(7000),
total_notional_value: dec!(100000),
total_margin_requirement: dec!(2000),
roi_percentage: dec!(7.0),
position_count: 5,
};
assert_eq!(portfolio_pnl.total_pnl, dec!(7000));
assert_eq!(portfolio_pnl.roi_percentage, dec!(7.0));
assert_eq!(portfolio_pnl.position_count, 5);
}
}