1use tokio::sync::Mutex;
2
3use rusqlite::{params, Connection, Result as SqlResult};
4use serde::{Deserialize, Serialize};
5
6#[derive(Debug, Clone, Serialize, Deserialize)]
8pub struct Position {
9 pub id: String,
10 pub market: String,
11 pub side: String,
12 pub size: f64,
13 pub entry_price: f64,
14 pub current_price: Option<f64>,
15 pub status: String,
16 pub pnl: Option<f64>,
17 pub mode: String,
18 pub strategy: Option<String>,
19 pub opened_at: String,
20 pub closed_at: Option<String>,
21 pub close_reason: Option<String>,
22}
23
24#[derive(Debug, Clone, Serialize, Deserialize)]
26pub struct ExecutionMetric {
27 pub order_id: String,
28 pub market: String,
29 pub side: String,
30 pub requested_size: f64,
31 pub filled_size: f64,
32 pub status: String,
33 pub mode: String,
34}
35
36#[derive(Debug, Clone, Serialize, Deserialize)]
38pub struct ExecutionSummary {
39 pub total_orders: u64,
40 pub full_fills: u64,
41 pub partial_fills: u64,
42 pub zero_fills: u64,
43 pub avg_fill_rate_pct: f64,
44}
45
46#[derive(Debug, Clone, Serialize, Deserialize)]
48pub struct PnlSummary {
49 pub total_pnl: f64,
50 pub win_count: u32,
51 pub loss_count: u32,
52 pub win_rate: f64,
53 pub best_trade: Option<(String, f64)>,
54 pub worst_trade: Option<(String, f64)>,
55}
56
57#[derive(Debug, thiserror::Error)]
59pub enum PositionError {
60 #[error("Database error: {0}")]
61 Db(#[from] rusqlite::Error),
62 #[error("Position not found: {0}")]
63 NotFound(String),
64}
65
66pub struct PositionManager {
75 db: Mutex<Connection>,
76}
77
78impl PositionManager {
79 pub fn new(db_path: &str) -> Result<Self, PositionError> {
82 let db = Connection::open(db_path)?;
83 db.execute_batch("PRAGMA journal_mode=WAL; PRAGMA foreign_keys=ON;")?;
84 db.execute(
85 "CREATE TABLE IF NOT EXISTS positions (
86 id TEXT PRIMARY KEY,
87 market TEXT NOT NULL,
88 side TEXT NOT NULL CHECK(side IN ('long','short')),
89 size REAL NOT NULL,
90 entry_price REAL NOT NULL,
91 current_price REAL,
92 status TEXT NOT NULL DEFAULT 'open' CHECK(status IN ('open','closed')),
93 pnl REAL,
94 mode TEXT NOT NULL CHECK(mode IN ('live','paper')),
95 strategy TEXT,
96 opened_at TEXT NOT NULL,
97 closed_at TEXT,
98 close_reason TEXT
99 )",
100 [],
101 )?;
102 db.execute(
103 "CREATE TABLE IF NOT EXISTS execution_metrics (
104 id INTEGER PRIMARY KEY AUTOINCREMENT,
105 timestamp TEXT NOT NULL,
106 order_id TEXT NOT NULL,
107 market TEXT NOT NULL,
108 side TEXT NOT NULL,
109 requested_size REAL NOT NULL,
110 filled_size REAL NOT NULL,
111 fill_rate REAL NOT NULL,
112 status TEXT NOT NULL,
113 mode TEXT NOT NULL
114 )",
115 [],
116 )?;
117 Ok(Self { db: Mutex::new(db) })
118 }
119
120 #[cfg(test)]
122 pub fn in_memory() -> Result<Self, PositionError> {
123 Self::new(":memory:")
124 }
125
126 #[cfg(test)]
128 pub async fn lock_db_for_test(&self) -> tokio::sync::MutexGuard<'_, Connection> {
129 self.db.lock().await
130 }
131
132 pub async fn open_position(&self, pos: &Position) -> Result<(), PositionError> {
134 let db = self.db.lock().await;
135 db.execute(
136 "INSERT INTO positions
137 (id, market, side, size, entry_price, current_price,
138 status, pnl, mode, strategy, opened_at, closed_at, close_reason)
139 VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12,?13)",
140 params![
141 pos.id,
142 pos.market,
143 pos.side,
144 pos.size,
145 pos.entry_price,
146 pos.current_price,
147 pos.status,
148 pos.pnl,
149 pos.mode,
150 pos.strategy,
151 pos.opened_at,
152 pos.closed_at,
153 pos.close_reason,
154 ],
155 )?;
156 Ok(())
157 }
158
159 pub async fn close_position(
162 &self,
163 id: &str,
164 price: f64,
165 reason: &str,
166 ) -> Result<(), PositionError> {
167 let db = self.db.lock().await;
168 let pos = Self::get_position_inner(&db, id)?
169 .ok_or_else(|| PositionError::NotFound(id.to_string()))?;
170
171 let pnl = compute_pnl(&pos.side, pos.entry_price, price, pos.size);
172 let now = chrono::Utc::now().to_rfc3339();
173
174 let changed = db.execute(
175 "UPDATE positions
176 SET status = 'closed',
177 current_price = ?1,
178 pnl = ?2,
179 closed_at = ?3,
180 close_reason = ?4
181 WHERE id = ?5 AND status = 'open'",
182 params![price, pnl, now, reason, id],
183 )?;
184
185 if changed == 0 {
186 return Err(PositionError::NotFound(id.to_string()));
187 }
188 Ok(())
189 }
190
191 pub async fn update_price(&self, id: &str, price: f64) -> Result<(), PositionError> {
193 let db = self.db.lock().await;
194 let pos = Self::get_position_inner(&db, id)?
195 .ok_or_else(|| PositionError::NotFound(id.to_string()))?;
196
197 let pnl = compute_pnl(&pos.side, pos.entry_price, price, pos.size);
198
199 let changed = db.execute(
200 "UPDATE positions SET current_price = ?1, pnl = ?2 WHERE id = ?3 AND status = 'open'",
201 params![price, pnl, id],
202 )?;
203
204 if changed == 0 {
205 return Err(PositionError::NotFound(id.to_string()));
206 }
207 Ok(())
208 }
209
210 pub async fn list_open(&self) -> Result<Vec<Position>, PositionError> {
212 let db = self.db.lock().await;
213 let mut stmt =
214 db.prepare("SELECT * FROM positions WHERE status = 'open' ORDER BY opened_at DESC")?;
215 let rows = stmt.query_map([], row_to_position)?;
216 rows.collect::<SqlResult<Vec<_>>>()
217 .map_err(PositionError::from)
218 }
219
220 pub async fn list_closed(&self, limit: usize) -> Result<Vec<Position>, PositionError> {
222 let db = self.db.lock().await;
223 let mut stmt = db.prepare(
224 "SELECT * FROM positions WHERE status = 'closed' ORDER BY closed_at DESC LIMIT ?1",
225 )?;
226 let rows = stmt.query_map(params![limit as i64], row_to_position)?;
227 rows.collect::<SqlResult<Vec<_>>>()
228 .map_err(PositionError::from)
229 }
230
231 pub async fn get_pnl_summary(&self, days: u32) -> Result<PnlSummary, PositionError> {
234 let cutoff = chrono::Utc::now() - chrono::Duration::days(i64::from(days));
235 let cutoff_str = cutoff.to_rfc3339();
236
237 let db = self.db.lock().await;
238 let mut stmt = db.prepare(
239 "SELECT id, pnl FROM positions
240 WHERE status = 'closed' AND closed_at >= ?1
241 ORDER BY pnl DESC",
242 )?;
243
244 let trades: Vec<(String, f64)> = stmt
245 .query_map(params![cutoff_str], |row| {
246 Ok((row.get::<_, String>(0)?, row.get::<_, f64>(1)?))
247 })?
248 .collect::<SqlResult<Vec<_>>>()?;
249
250 let mut total_pnl = 0.0;
251 let mut win_count: u32 = 0;
252 let mut loss_count: u32 = 0;
253 let mut best: Option<(String, f64)> = None;
254 let mut worst: Option<(String, f64)> = None;
255
256 for (id, pnl) in &trades {
257 total_pnl += pnl;
258 if *pnl >= 0.0 {
259 win_count += 1;
260 } else {
261 loss_count += 1;
262 }
263 if best.as_ref().map_or(true, |(_, b)| pnl > b) {
264 best = Some((id.clone(), *pnl));
265 }
266 if worst.as_ref().map_or(true, |(_, w)| pnl < w) {
267 worst = Some((id.clone(), *pnl));
268 }
269 }
270
271 let total = win_count + loss_count;
272 let win_rate = if total > 0 {
273 f64::from(win_count) / f64::from(total)
274 } else {
275 0.0
276 };
277
278 Ok(PnlSummary {
279 total_pnl,
280 win_count,
281 loss_count,
282 win_rate,
283 best_trade: best,
284 worst_trade: worst,
285 })
286 }
287
288 pub async fn record_execution(&self, metric: ExecutionMetric) -> Result<(), PositionError> {
290 let db = self.db.lock().await;
291 let fill_rate = if metric.requested_size > 0.0 {
292 (metric.filled_size / metric.requested_size) * 100.0
293 } else {
294 0.0
295 };
296 let now = chrono::Utc::now().to_rfc3339();
297 db.execute(
298 "INSERT INTO execution_metrics
299 (timestamp, order_id, market, side, requested_size, filled_size,
300 fill_rate, status, mode)
301 VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9)",
302 params![
303 now,
304 metric.order_id,
305 metric.market,
306 metric.side,
307 metric.requested_size,
308 metric.filled_size,
309 fill_rate,
310 metric.status,
311 metric.mode,
312 ],
313 )?;
314 Ok(())
315 }
316
317 pub async fn get_execution_summary(&self) -> Result<ExecutionSummary, PositionError> {
319 let db = self.db.lock().await;
320 let mut stmt = db.prepare("SELECT fill_rate, status FROM execution_metrics")?;
321 let rows: Vec<(f64, String)> = stmt
322 .query_map([], |row| {
323 Ok((row.get::<_, f64>(0)?, row.get::<_, String>(1)?))
324 })?
325 .collect::<SqlResult<Vec<_>>>()?;
326
327 let total_orders = rows.len() as u64;
328 let mut full_fills: u64 = 0;
329 let mut partial_fills: u64 = 0;
330 let mut zero_fills: u64 = 0;
331 let mut fill_rate_sum: f64 = 0.0;
332
333 for (fill_rate, status) in &rows {
334 fill_rate_sum += fill_rate;
335 match status.as_str() {
336 "partial_fill" => partial_fills += 1,
337 "resting" => zero_fills += 1,
338 _ => {
339 if *fill_rate >= 100.0 - f64::EPSILON {
341 full_fills += 1;
342 } else if *fill_rate > 0.0 {
343 partial_fills += 1;
344 } else {
345 zero_fills += 1;
346 }
347 }
348 }
349 }
350
351 let avg_fill_rate_pct = if total_orders > 0 {
352 fill_rate_sum / total_orders as f64
353 } else {
354 0.0
355 };
356
357 Ok(ExecutionSummary {
358 total_orders,
359 full_fills,
360 partial_fills,
361 zero_fills,
362 avg_fill_rate_pct,
363 })
364 }
365
366 pub async fn get_position(&self, id: &str) -> Result<Option<Position>, PositionError> {
368 let db = self.db.lock().await;
369 Self::get_position_inner(&db, id)
370 }
371
372 fn get_position_inner(db: &Connection, id: &str) -> Result<Option<Position>, PositionError> {
374 let mut stmt = db.prepare("SELECT * FROM positions WHERE id = ?1")?;
375 let mut rows = stmt.query_map(params![id], row_to_position)?;
376 match rows.next() {
377 Some(row) => Ok(Some(row?)),
378 None => Ok(None),
379 }
380 }
381}
382
383fn compute_pnl(side: &str, entry: f64, exit: f64, size: f64) -> f64 {
385 match side {
386 "long" => (exit - entry) * size,
387 "short" => (entry - exit) * size,
388 _ => 0.0,
389 }
390}
391
392fn row_to_position(row: &rusqlite::Row) -> SqlResult<Position> {
394 Ok(Position {
395 id: row.get("id")?,
396 market: row.get("market")?,
397 side: row.get("side")?,
398 size: row.get("size")?,
399 entry_price: row.get("entry_price")?,
400 current_price: row.get("current_price")?,
401 status: row.get("status")?,
402 pnl: row.get("pnl")?,
403 mode: row.get("mode")?,
404 strategy: row.get("strategy")?,
405 opened_at: row.get("opened_at")?,
406 closed_at: row.get("closed_at")?,
407 close_reason: row.get("close_reason")?,
408 })
409}
410
411#[cfg(test)]
412mod tests {
413 use super::*;
414
415 fn make_position(id: &str, market: &str, side: &str, size: f64, entry: f64) -> Position {
416 Position {
417 id: id.to_string(),
418 market: market.to_string(),
419 side: side.to_string(),
420 size,
421 entry_price: entry,
422 current_price: None,
423 status: "open".to_string(),
424 pnl: None,
425 mode: "paper".to_string(),
426 strategy: Some("test-strat".to_string()),
427 opened_at: chrono::Utc::now().to_rfc3339(),
428 closed_at: None,
429 close_reason: None,
430 }
431 }
432
433 #[tokio::test]
434 async fn test_open_and_get_position() {
435 let pm = PositionManager::in_memory().unwrap();
436 let pos = make_position("pos-1", "BTC-PERP", "long", 0.5, 60000.0);
437 pm.open_position(&pos).await.unwrap();
438
439 let fetched = pm.get_position("pos-1").await.unwrap().unwrap();
440 assert_eq!(fetched.market, "BTC-PERP");
441 assert_eq!(fetched.side, "long");
442 assert_eq!(fetched.size, 0.5);
443 assert_eq!(fetched.entry_price, 60000.0);
444 assert_eq!(fetched.status, "open");
445 }
446
447 #[tokio::test]
448 async fn test_get_nonexistent_returns_none() {
449 let pm = PositionManager::in_memory().unwrap();
450 assert!(pm.get_position("nonexistent").await.unwrap().is_none());
451 }
452
453 #[tokio::test]
454 async fn test_close_position_long_profit() {
455 let pm = PositionManager::in_memory().unwrap();
456 let pos = make_position("pos-long", "ETH-PERP", "long", 2.0, 3000.0);
457 pm.open_position(&pos).await.unwrap();
458
459 pm.close_position("pos-long", 3500.0, "take-profit")
460 .await
461 .unwrap();
462
463 let closed = pm.get_position("pos-long").await.unwrap().unwrap();
464 assert_eq!(closed.status, "closed");
465 assert!((closed.pnl.unwrap() - 1000.0).abs() < 1e-6);
466 assert_eq!(closed.close_reason.as_deref(), Some("take-profit"));
467 assert!(closed.closed_at.is_some());
468 }
469
470 #[tokio::test]
471 async fn test_close_position_short_profit() {
472 let pm = PositionManager::in_memory().unwrap();
473 let pos = make_position("pos-short", "SOL-PERP", "short", 10.0, 100.0);
474 pm.open_position(&pos).await.unwrap();
475
476 pm.close_position("pos-short", 90.0, "take-profit")
477 .await
478 .unwrap();
479
480 let closed = pm.get_position("pos-short").await.unwrap().unwrap();
481 assert!((closed.pnl.unwrap() - 100.0).abs() < 1e-6);
482 }
483
484 #[tokio::test]
485 async fn test_close_position_long_loss() {
486 let pm = PositionManager::in_memory().unwrap();
487 let pos = make_position("pos-loss", "BTC-PERP", "long", 1.0, 60000.0);
488 pm.open_position(&pos).await.unwrap();
489
490 pm.close_position("pos-loss", 59000.0, "stop-loss")
491 .await
492 .unwrap();
493
494 let closed = pm.get_position("pos-loss").await.unwrap().unwrap();
495 assert!((closed.pnl.unwrap() - (-1000.0)).abs() < 1e-6);
496 }
497
498 #[tokio::test]
499 async fn test_close_nonexistent_position() {
500 let pm = PositionManager::in_memory().unwrap();
501 let err = pm
502 .close_position("ghost", 100.0, "reason")
503 .await
504 .unwrap_err();
505 assert!(matches!(err, PositionError::NotFound(_)));
506 }
507
508 #[tokio::test]
509 async fn test_update_price() {
510 let pm = PositionManager::in_memory().unwrap();
511 let pos = make_position("pos-upd", "BTC-PERP", "long", 1.0, 60000.0);
512 pm.open_position(&pos).await.unwrap();
513
514 pm.update_price("pos-upd", 61000.0).await.unwrap();
515
516 let fetched = pm.get_position("pos-upd").await.unwrap().unwrap();
517 assert_eq!(fetched.current_price, Some(61000.0));
518 assert!((fetched.pnl.unwrap() - 1000.0).abs() < 1e-6);
519 }
520
521 #[tokio::test]
522 async fn test_list_open_and_closed() {
523 let pm = PositionManager::in_memory().unwrap();
524 pm.open_position(&make_position("a", "BTC-PERP", "long", 1.0, 60000.0))
525 .await
526 .unwrap();
527 pm.open_position(&make_position("b", "ETH-PERP", "short", 5.0, 3000.0))
528 .await
529 .unwrap();
530 pm.open_position(&make_position("c", "SOL-PERP", "long", 10.0, 100.0))
531 .await
532 .unwrap();
533
534 assert_eq!(pm.list_open().await.unwrap().len(), 3);
535 assert_eq!(pm.list_closed(10).await.unwrap().len(), 0);
536
537 pm.close_position("a", 61000.0, "tp").await.unwrap();
538
539 assert_eq!(pm.list_open().await.unwrap().len(), 2);
540 assert_eq!(pm.list_closed(10).await.unwrap().len(), 1);
541 }
542
543 #[tokio::test]
544 async fn test_pnl_summary() {
545 let pm = PositionManager::in_memory().unwrap();
546
547 pm.open_position(&make_position("w1", "BTC-PERP", "long", 1.0, 60000.0))
549 .await
550 .unwrap();
551 pm.open_position(&make_position("w2", "ETH-PERP", "short", 10.0, 3000.0))
552 .await
553 .unwrap();
554 pm.open_position(&make_position("l1", "SOL-PERP", "long", 100.0, 100.0))
555 .await
556 .unwrap();
557
558 pm.close_position("w1", 62000.0, "tp").await.unwrap(); pm.close_position("w2", 2800.0, "tp").await.unwrap(); pm.close_position("l1", 95.0, "sl").await.unwrap(); let summary = pm.get_pnl_summary(30).await.unwrap();
563 assert!((summary.total_pnl - 3500.0).abs() < 1e-6);
564 assert_eq!(summary.win_count, 2);
565 assert_eq!(summary.loss_count, 1);
566 assert!((summary.win_rate - 2.0 / 3.0).abs() < 1e-6);
567
568 let (best_id, best_pnl) = summary.best_trade.unwrap();
569 assert!((best_pnl - 2000.0).abs() < 1e-6);
570 assert!(best_id == "w1" || best_id == "w2");
572
573 let (_, worst_pnl) = summary.worst_trade.unwrap();
574 assert!((worst_pnl - (-500.0)).abs() < 1e-6);
575 }
576
577 #[tokio::test]
578 async fn test_pnl_summary_empty() {
579 let pm = PositionManager::in_memory().unwrap();
580 let summary = pm.get_pnl_summary(30).await.unwrap();
581 assert_eq!(summary.total_pnl, 0.0);
582 assert_eq!(summary.win_count, 0);
583 assert_eq!(summary.loss_count, 0);
584 assert_eq!(summary.win_rate, 0.0);
585 assert!(summary.best_trade.is_none());
586 assert!(summary.worst_trade.is_none());
587 }
588
589 #[tokio::test]
590 async fn test_duplicate_id_fails() {
591 let pm = PositionManager::in_memory().unwrap();
592 let pos = make_position("dup", "BTC-PERP", "long", 1.0, 60000.0);
593 pm.open_position(&pos).await.unwrap();
594 assert!(pm.open_position(&pos).await.is_err());
595 }
596
597 #[tokio::test]
598 async fn test_invalid_side_rejected() {
599 let pm = PositionManager::in_memory().unwrap();
600 let mut pos = make_position("bad", "BTC-PERP", "long", 1.0, 60000.0);
601 pos.side = "sideways".to_string();
602 assert!(pm.open_position(&pos).await.is_err());
603 }
604
605 fn make_metric(order_id: &str, status: &str, requested: f64, filled: f64) -> ExecutionMetric {
608 ExecutionMetric {
609 order_id: order_id.to_string(),
610 market: "BTC-PERP".to_string(),
611 side: "buy".to_string(),
612 requested_size: requested,
613 filled_size: filled,
614 status: status.to_string(),
615 mode: "paper".to_string(),
616 }
617 }
618
619 #[tokio::test]
620 async fn test_execution_summary_empty() {
621 let pm = PositionManager::in_memory().unwrap();
622 let summary = pm.get_execution_summary().await.unwrap();
623 assert_eq!(summary.total_orders, 0);
624 assert_eq!(summary.full_fills, 0);
625 assert_eq!(summary.partial_fills, 0);
626 assert_eq!(summary.zero_fills, 0);
627 assert_eq!(summary.avg_fill_rate_pct, 0.0);
628 }
629
630 #[tokio::test]
631 async fn test_record_and_summarize_executions() {
632 let pm = PositionManager::in_memory().unwrap();
633
634 pm.record_execution(make_metric("o1", "filled", 1.0, 1.0))
636 .await
637 .unwrap();
638 pm.record_execution(make_metric("o2", "partial_fill", 1.0, 0.5))
640 .await
641 .unwrap();
642 pm.record_execution(make_metric("o3", "resting", 1.0, 0.0))
644 .await
645 .unwrap();
646
647 let summary = pm.get_execution_summary().await.unwrap();
648 assert_eq!(summary.total_orders, 3);
649 assert_eq!(summary.full_fills, 1);
650 assert_eq!(summary.partial_fills, 1);
651 assert_eq!(summary.zero_fills, 1);
652 assert!((summary.avg_fill_rate_pct - 50.0).abs() < 1e-6);
654 }
655
656 #[tokio::test]
657 async fn test_all_full_fills() {
658 let pm = PositionManager::in_memory().unwrap();
659 pm.record_execution(make_metric("a", "filled", 2.0, 2.0))
660 .await
661 .unwrap();
662 pm.record_execution(make_metric("b", "ok", 0.5, 0.5))
663 .await
664 .unwrap();
665
666 let summary = pm.get_execution_summary().await.unwrap();
667 assert_eq!(summary.total_orders, 2);
668 assert_eq!(summary.full_fills, 2);
669 assert_eq!(summary.partial_fills, 0);
670 assert_eq!(summary.zero_fills, 0);
671 assert!((summary.avg_fill_rate_pct - 100.0).abs() < 1e-6);
672 }
673}