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>
918 lines
31 KiB
Rust
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);
|
|
}
|
|
}
|