Coverage for src/finbot/backtest/trades.py: 100%
48 statements
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-21 17:12 +0000
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-21 17:12 +0000
1from dataclasses import dataclass
2from datetime import datetime, timedelta
3from typing import Any
5from finbot.execution.fill import Fill
8@dataclass(frozen=True)
9class Trade:
10 symbol: str
12 entry_time: datetime
13 exit_time: datetime
15 quantity: float
17 entry_price: float
18 exit_price: float
20 entry_fee: float = 0.0
21 exit_fee: float = 0.0
23 @property
24 def gross_pnl(self) -> float:
25 return (self.exit_price - self.entry_price) * self.quantity
27 @property
28 def fees(self) -> float:
29 return self.entry_fee + self.exit_fee
31 @property
32 def net_pnl(self) -> float:
33 return self.gross_pnl - self.fees
35 @property
36 def pnl(self) -> float:
37 """net trade PnL, including entry and exit fees"""
38 return self.net_pnl
40 @property
41 def return_pct(self) -> float:
42 return (self.exit_price - self.entry_price) / self.entry_price
44 @property
45 def duration(self) -> timedelta:
46 return self.exit_time - self.entry_time
48 def serialize(self) -> dict[str, Any]: # pragma: no cover
49 return {
50 "symbol": self.symbol,
51 "entry_time": self.entry_time.isoformat(),
52 "exit_time": self.exit_time.isoformat(),
53 "quantity": self.quantity,
54 "entry_price": self.entry_price,
55 "exit_price": self.exit_price,
56 "entry_fee": self.entry_fee,
57 "exit_fee": self.exit_fee,
58 "gross_pnl": self.gross_pnl,
59 "fees": self.fees,
60 "net_pnl": self.net_pnl,
61 "return_pct": self.return_pct,
62 "duration_seconds": self.duration.total_seconds(),
63 }
66def trades_from_fills(fills: list[Fill]) -> list[Trade]:
67 trades: list[Trade] = []
68 open_fills: dict[str, Fill] = {}
70 for fill in fills:
71 if fill.side == "buy":
72 if fill.symbol in open_fills:
73 raise ValueError(f"Already have an open trade for {fill.symbol}")
74 open_fills[fill.symbol] = fill
76 if fill.side == "sell":
77 if fill.symbol not in open_fills:
78 raise ValueError(f"No open trade for {fill.symbol} to close")
80 entry_fill = open_fills[fill.symbol]
81 trade = Trade(
82 symbol=fill.symbol,
83 entry_time=entry_fill.timestamp,
84 exit_time=fill.timestamp,
85 quantity=entry_fill.quantity,
86 entry_price=entry_fill.price,
87 exit_price=fill.price,
88 entry_fee=entry_fill.fee,
89 exit_fee=fill.fee,
90 )
91 trades.append(trade)
92 del open_fills[fill.symbol]
94 return trades